Some line integral examples of the Fundamental theorem of geometric calculus

January 20, 2026 math and physics play No comments , , , , , , , ,

[Click here for a PDF version of this post]

On my discord server, Frank asked about his attempt to demonstrate an example line integral computation of the fundamental theorem of geometric calculus.

Before working through his example, and some others, it is first worth restating the
line integral specialization of the \textit{Fundamental theorem of geometric calculus}:

Theorem 1.1: Fundamental theorem of geometric calculus (line integral version.)

Given multivectors \(F, G \), a single variable parameterization \( \Bx = \Bx(u) \), with line element \( d\Bx = du \Bx_u \), \( \Bx_u = \PDi{u}{\Bx} \), \( \boldpartial = \Bx^u \PDi{u}{} \), and \( \Bx^u \cdot \Bx_u = 1 \), then
the line integral is related to the boundary by
\begin{equation*}
\int F d\Bx \boldpartial G = \evalbar{F G}{\Delta u},
\end{equation*}
(with the \( \boldpartial \) acting bidirectionally on \( F, G \).)

It is very important to point out that the derivative operator here is the vector derivative, and not the gradient. Roughly speaking, the vector derivative is the projection of the gradient onto the tangent space. In this case, the tangent space is just the line in the direction \( \Bx_u \), which may vary along the parameterized path.

Here are some examples of some one variable parameterizations, all in two dimensions

  1. \( \Bx = u \Be_1 + y_0 \Be_2 \).
    We compute
    \begin{equation}\label{eqn:lineintegralExamples:20}
    \begin{aligned}
    \Bx_u &= \PD{\Bx}{u} = \Be_1 \\
    \Bx^u &= \Be_1 \\
    d\Bx &= du \Be_1 \\
    \boldpartial &= \Be_1 \PD{u}{}.
    \end{aligned}
    \end{equation}
    and \( d\Bx \boldpartial = \PDi{u}{} \).
    The fundamental theorem is really just a statement that
    \begin{equation}\label{eqn:lineintegralExamples:40}
    \int \PD{u}{} \lr{ F G } du = \evalbar{ F G }{\Delta u}.
    \end{equation}

  2. \( \Bx = \alpha u \Be_1 + \beta u \Be_2 \), where \( \alpha, \beta \) are constants. i.e.: a line, but not necessarily on the horizontal this time.
    This time, we compute
    \begin{equation}\label{eqn:lineintegralExamples:60}
    \begin{aligned}
    \Bx_u &= \alpha \Be_1 + \beta \Be_2 \\
    \Bx^u &= \inv{\Bx_u} = \frac{\alpha \Be_1 + \beta \Be_2}{\alpha^2 + \beta^2} \\
    d\Bx &= du \lr{ \alpha \Be_1 + \beta \Be_2 } \\
    \boldpartial &= \inv{\alpha \Be_1 + \beta \Be_2} \PD{u}{}.
    \end{aligned}
    \end{equation}
    Again, we have \( d\Bx \boldpartial = \PDi{u}{} \), and the story repeats.

  3. \( \Bx = R \Be_1 e^{i\theta}, i = \Be_1 \Be_2 \). This time we are going along a circular arc.

    Let \( \rcap = \Be_1 e^{i\theta} \), and \(\thetacap = \Be_2 e^{i\theta} \). We can compute
    \begin{equation}\label{eqn:lineintegralExamples:80}
    \begin{aligned}
    \Bx_\theta &= R \Be_2 e^{i\theta} = R \thetacap \\
    \Bx^\theta &= \inv{\Bx_\theta} = \inv{ R \Be_2 e^{i\theta} } = \inv{R} \thetacap \\
    d\Bx &= d\theta \thetacap \\
    \boldpartial &= \frac{\thetacap}{R} \PD{\theta}{}.
    \end{aligned}
    \end{equation}
    This time, probably to no suprise, we have \( d\Bx \boldpartial = \PDi{\theta}{} \), so the fundamental theorem for this parameterization is a statement that
    \begin{equation}\label{eqn:lineintegralExamples:100}
    \int \PD{\theta}{} \lr{ F G } d\theta = \evalbar{ F G }{\Delta \theta}.
    \end{equation}

  4. \( \Bx = r e^{i\theta_0} \), where \( \theta_0 \) is a constant. We’ve already computed this above with a Cartesian representation of a line, but can do it again this time with an explicitly radial parameterization. We compute
    \begin{equation}\label{eqn:lineintegralExamples:120}
    \begin{aligned}
    \Bx_r &= \Be_1 e^{i \theta_0} \\
    \Bx^r &= \inv{\Bx_r} = \Be_1 e^{i \theta_0} \\
    d\Bx &= dr \Be_1 e^{i \theta_0} \\
    \boldpartial &= e^{i \theta_0} \PD{r}{}.
    \end{aligned}
    \end{equation}
    This time, \( d\Bx \boldpartial = \PDi{r}{} \), and the fundamental theorem for this parameterization is a statement that
    \begin{equation}\label{eqn:lineintegralExamples:140}
    \int \PD{r}{} \lr{ F G } dr = \evalbar{ F G }{\Delta r}.
    \end{equation}

Observe that we do not get the same result if we use the gradient instead of the vector derivative. We may only make a gradient substitution for the vector derivative when the dimension of the hypervolume integral equals the dimension of the vector space itself. For a line integral that would mean we are restricting the domain of the underlying vector space to \(\mathbb{R}^1\), which isn’t a very interesting case for geometric algebra.

In Frank’s example, he was working with a generating vector space of \(\mathbb{R}^2\), with the horizontal parameterization \( \Bx = u \Be_1 + y_0 \Be_2 \) that we used in the first example (with \( F = 1, G = x y i \), where \( i = \Be_1 \Be_2 \), the pseudoscalar for the space).

Let’s see what happens if we compute a similar integral, but swapping out the vector derivative with the gradient
\begin{equation}\label{eqn:lineintegralExamples:160}
\begin{aligned}
\int d\Bx \spacegrad x y i
&=
\int du \Be_1 \lr{ \Be_1 \partial_x + \Be_2 \partial_y } ( x y i ) \\
&=
\int du \Be_1 \lr{ \Be_1 y + \Be_2 x } i \\
&=
\int du \lr{ y + i x } i \\
&=
\int du \lr{ y_0 + i u } i \\
&=
\lr{\Delta x} y_0 i – \frac{x_1^2}{2} + \frac{x_0^2}{2}.
\end{aligned}
\end{equation}
As well as the pseudoscalar term that we had when evaluating the fundamental theorem integral, this time we have an extra scalar term, a contribution that goes back to the \( y \) component of the gradient. There is nothing wrong with performing such an integral, but it’s not an instance of the fundamental theorem, and the same tidy answer should not be expected. In Frank’s original example, he also didn’t put the \( \Bx \) adjacent to the differential operator, which is required to get the perfect cancelation of the tangent space vectors that we’ve seen in the evaluations above.

One more version tagged for the silly compiler: V7

January 4, 2026 clang/llvm , ,

V7 of the silly compiler is tagged. (EDIT: I’d previously announced this as V8, but I jumped the gun, including having an AI generate a “V8” image for me below. The newly tagged version is V7. There is no V8 yet, and only when I create it, will the V8 image below will be justified.)

This version makes a couple small bug fixes, and a bunch of maintenance changes, mostly related to location information in the builder/parser, which is now considerably simpler. It also adds support for PRINT numeric-literal, and implements an ELIF statement. That last was in the grammar, but had no builder support.

Adding the ELIF feature actually simplifed things. I didn’t try to add my own silly.if MLIR operator, with intrinsic support for this else-if construct that I had in the grammar. Instead I just used scf.if — essentially making a transformation of the following form in the builder:

IF ( x )
{
  statement1;
}
ELIF ( y )
{
  statement2;
}

to

IF ( x )
{
  statement1;
}
ELSE
{
  IF ( y )
  {
    statement2;
  }
}

In my original implementation of IF/ELSE, I only generated the ELSE region and block when the user code had an ELSE statement. Then when the ANTRL4 enterElseStatement was called, I’d then generate the else region and block (setting the insertion point at that point, so that the following statements would then populate that block.). Then when exitElseStatement was run, I’d generate the scf::Yield operation to terminate the block.

With the implementation of ELIF, I changed the scf::IfOp generation to include empty then and else regions. When this is done up front by default, the constructor adds the scf::Yield operators automatically — all that’s required is setting the insertion point. I then only have to push/pop a single insertion point, adjusting it (without push) for each ELIF/ELSE callback in the ANTLR4 tree walker.

Here are some examples:

INT32 x;

x = 3;

IF ( x < 4 )
{
  INT32 y;
  y = 42;
  PRINT y;
};

PRINT "Done.";

This is an IF without an ELSE, but the MLIR now has an empty else block:

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "y"} : () -> ()
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c3_i64 = arith.constant 3 : i64
      silly.assign @x = %c3_i64 : i64
      %0 = silly.load @x : i32
      %c4_i64 = arith.constant 4 : i64
      %1 = "silly.less"(%0, %c4_i64) : (i32, i64) -> i1
      scf.if %1 {
        %c42_i64 = arith.constant 42 : i64
        silly.assign @y = %c42_i64 : i64
        %3 = silly.load @y : i32
        silly.print %3 : i32
      } else {
      }
      %2 = "silly.string_literal"() <{value = "Done."}> : () -> !llvm.ptr
      silly.print %2 : !llvm.ptr
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

Here's one with an ELIF:

INT32 x; // line 1

x = 3; // line 3

IF ( x < 4 ) // line 5
{
   PRINT x; // line 7
}
ELIF ( x > 5 ) // line 9
{
   PRINT "Bug if we get here."; // line 11
};

PRINT 42; // line 13

This one has a nested if/else within the else:

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c3_i64 = arith.constant 3 : i64
      silly.assign @x = %c3_i64 : i64
      %0 = silly.load @x : i32
      %c4_i64 = arith.constant 4 : i64
      %1 = "silly.less"(%0, %c4_i64) : (i32, i64) -> i1
      scf.if %1 {
        %2 = silly.load @x : i32
        silly.print %2 : i32
      } else {
        %2 = silly.load @x : i32
        %c5_i64 = arith.constant 5 : i64
        %3 = "silly.less"(%c5_i64, %2) : (i64, i32) -> i1
        scf.if %3 {
          %4 = "silly.string_literal"() <{value = "Bug if we get here."}> : () -> !llvm.ptr
          silly.print %4 : !llvm.ptr
        } else {
        }
      }
      %c42_i64 = arith.constant 42 : i64
      silly.print %c42_i64 : i64
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

Effectively, the existing IF and ELSE implementation has been moved into helper functions, leaving the guts of all the walking functions for IF/ELIF/ELSE almost trivial:

    void MLIRListener::enterIfStatement( SillyParser::IfStatementContext *ctx )
    try
    {
        assert( ctx );
        mlir::Location loc = getStartLocation( ctx );

        SillyParser::BooleanValueContext *booleanValue = ctx->booleanValue();
        assert( booleanValue );

        createIf( loc, booleanValue, true );
    }
    CATCH_USER_ERROR

    void MLIRListener::enterElseStatement( SillyParser::ElseStatementContext *ctx )
    try
    {
        assert( ctx );
        mlir::Location loc = getStartLocation( ctx );

        selectElseBlock( loc, ctx->getText() );
    }
    CATCH_USER_ERROR

    void MLIRListener::enterElifStatement( SillyParser::ElifStatementContext *ctx )
    try
    {
        assert( ctx );
        mlir::Location loc = getStartLocation( ctx );

        selectElseBlock( loc, ctx->getText() );

        SillyParser::BooleanValueContext *booleanValue = ctx->booleanValue();

        createIf( loc, booleanValue, false );
    }
    CATCH_USER_ERROR

    void MLIRListener::exitIfelifelse( SillyParser::IfelifelseContext *ctx )
    try
    {
        // Restore EXACTLY where we were before creating the scf.if
        // This places new ops right AFTER the scf.if
        builder.restoreInsertionPoint( insertionPointStack.back() );
        insertionPointStack.pop_back();
    }
    CATCH_USER_ERROR

The selectElseBlock function used to be a createElseBlock. Now it just sets the insertion point, which takes a bit of work to figure out where it is:

    void MLIRListener::selectElseBlock( mlir::Location loc, const std::string & errorText )
    {
        mlir::scf::IfOp ifOp;

        // Temporarily restore the insertion point to right after the scf.if, to search for our current IfOp
        builder.restoreInsertionPoint( insertionPointStack.back() );

        // Now find the scf.if op that is just before the current insertion point
        mlir::Block *currentBlock = builder.getInsertionBlock();
        assert( currentBlock );
        mlir::Block::iterator ip = builder.getInsertionPoint();
        
        // The insertion point is at the position where new ops would be inserted.
        // So the operation just before it should be the scf.if
        if ( ip != currentBlock->begin() )
        {
            mlir::Operation *prevOp = &*( --ip );    // the op immediately before the insertion point
            ifOp = dyn_cast<mlir::scf::IfOp>( prevOp );
        }
    
        if ( !ifOp )
        {
            throw ExceptionWithContext(
                __FILE__, __LINE__, __func__,
                std::format( "{}internal error: Could not find scf.if op corresponding to this if statement\n",
                             formatLocation( loc ), errorText ) );
        }

        mlir::Region &elseRegion = ifOp.getElseRegion();
        mlir::Block &elseBlock = elseRegion.front();
        builder.setInsertionPointToStart( &elseBlock );
    }

The createIf helper is also pretty trivial:

    void MLIRListener::createIf( mlir::Location loc, SillyParser::BooleanValueContext *booleanValue, bool saveIP )
    {
        mlir::Value conditionPredicate = parsePredicate( loc, booleanValue );

        if ( saveIP )
        {
            insertionPointStack.push_back( builder.saveInsertionPoint() );
        } 

        mlir::scf::IfOp ifOp = builder.create<mlir::scf::IfOp>(
            loc, conditionPredicate,
            /*withElseRegion=*/true );
        
        mlir::Block &thenBlock = ifOp.getThenRegion().front();
        builder.setInsertionPointToStart( &thenBlock );
    }

Debugging wrong debug location info for a CALL in my silly language.

January 1, 2026 clang/llvm , , , , ,

Screenshot

Here’s a program in my silly language:

PRINT "hi"; // line 1

FUNCTION bar0 ( ) // line 3
{
    PRINT "bar0"; // line 5
    RETURN; // line 6
};

CALL bar0(); // line 9

I noticed that line stepping for this program has a “line number glitch”:

xpg:/home/pjoot/toycalculator/samples> gdb out/f
Reading symbols from out/f...
(gdb) b main
Breakpoint 1 at 0x400491: file f.silly, line 1.
(gdb) run
Starting program: /home/pjoot/toycalculator/samples/out/f 
Downloading separate debug info for system-supplied DSO at 0x7ffff7fc5000
[Thread debugging using libthread_db enabled]                                                                                           
Using host libthread_db library "/lib64/libthread_db.so.1".

Breakpoint 1, main () at f.silly:1
1       PRINT "hi"; // line 1
(gdb) b 9
Breakpoint 2 at 0x4004a0: file f.silly, line 9.
(gdb) c
Continuing.
hi

Breakpoint 2, main () at f.silly:9
9       CALL bar0(); // line 9
(gdb) s
bar0 () at f.silly:1
1       PRINT "hi"; // line 1
(gdb) n
5           PRINT "bar0"; // line 5
(gdb) 
bar0
6           RETURN; // line 6

(i.e.: from line 9, we should jump to line 3, then 5, but we first end up at line 1 instead of 3.)

I can see that things are wrong in the dwarfdump:

LOCAL_SYMBOLS:
< 1><0x0000002a>    DW_TAG_subprogram
                      DW_AT_low_pc                0x00000000
                      DW_AT_high_pc                18 
                      DW_AT_frame_base            len 0x0001: 0x57: 
                          DW_OP_reg7
                      DW_AT_linkage_name          bar0
                      DW_AT_name                  bar0
                      DW_AT_decl_file             0x00000001 ./f.silly
                      DW_AT_decl_line             0x00000001
                      DW_AT_type                  <0x00000064> Refers to: void
                      DW_AT_external              yes(1)
< 1><0x00000047>    DW_TAG_subprogram
                      DW_AT_low_pc                0x00000020
                      DW_AT_high_pc                25 
                      DW_AT_frame_base            len 0x0001: 0x57: 
                          DW_OP_reg7
                      DW_AT_linkage_name          main
                      DW_AT_name                  main
                      DW_AT_decl_file             0x00000001 ./f.silly
                      DW_AT_decl_line             0x00000001
                      DW_AT_type                  <0x0000006b> Refers to: int
                      DW_AT_external              yes(1)

The DW_AT_decl_line for the implicit main function for the program has a valid value (1), but the DW_AT_decl_line for the bar0 function shouldn’t be line 1.

Let’s have a peek at the LLVM-IR representation:

; ModuleID = 'f.silly'
source_filename = "f.silly"
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_1 = private constant [2 x i8] c"hi"
@str_0 = private constant [4 x i8] c"bar0"

declare void @__silly_print_string(i64, ptr)

define void @bar0() !dbg !4 {
  call void @__silly_print_string(i64 4, ptr @str_0), !dbg !8
  ret void, !dbg !9
}

define i32 @main() !dbg !10 {
  call void @__silly_print_string(i64 2, ptr @str_1), !dbg !14
  call void @bar0(), !dbg !15
  ret i32 0, !dbg !16
}

!llvm.dbg.cu = !{!0}
!llvm.ident = !{!2}
!llvm.module.flags = !{!3}

!0 = distinct !DICompileUnit(language: DW_LANG_C, file: !1, producer: "silly", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug)
!1 = !DIFile(filename: "f.silly", directory: ".")
!2 = !{!"silly V7"}
!3 = !{i32 2, !"Debug Info Version", i32 3}
!4 = distinct !DISubprogram(name: "bar0", linkageName: "bar0", scope: !1, file: !1, line: 1, type: !5, scopeLine: 1, spFlags: DISPFlagDefinition, unit: !0)
!5 = !DISubroutineType(types: !6)
!6 = !{!7}
!7 = !DIBasicType(name: "void")
!8 = !DILocation(line: 5, column: 11, scope: !4)
!9 = !DILocation(line: 6, column: 5, scope: !4)
!10 = distinct !DISubprogram(name: "main", linkageName: "main", scope: !1, file: !1, line: 1, type: !11, scopeLine: 1, spFlags: DISPFlagDefinition, unit: !0)
!11 = !DISubroutineType(types: !12)
!12 = !{!13}
!13 = !DIBasicType(name: "int", size: 32, encoding: DW_ATE_signed)
!14 = !DILocation(line: 1, column: 7, scope: !10)
!15 = !DILocation(line: 9, column: 11, scope: !10)
!16 = !DILocation(line: 1, column: 1, scope: !10)

The error is right there in the ‘!4 DISubprogram’, which has line 1, not 3. Also note that the scopeLine should also be 5, not 1.

Sure enough, I’ve got the line number hardcoded in lowering when I generate my DISubprogramAttr

    void LoweringContext::createFuncDebug( mlir::func::FuncOp funcOp )
    {
        if ( driverState.wantDebug )
        {
            ModuleInsertionPointGuard ip( mod, builder );

            mlir::MLIRContext* context = builder.getContext();
            std::string funcName = funcOp.getSymName().str();

            mlir::LLVM::DISubroutineTypeAttr subprogramType = createDISubroutineType( funcOp );

            mlir::LLVM::DISubprogramAttr sub = mlir::LLVM::DISubprogramAttr::get(
                context, mlir::DistinctAttr::create( builder.getUnitAttr() ), compileUnitAttr, fileAttr,
                builder.getStringAttr( funcName ), builder.getStringAttr( funcName ), fileAttr, 1, 1,
                mlir::LLVM::DISubprogramFlags::Definition, subprogramType, llvm::ArrayRef<mlir::LLVM::DINodeAttr>{},
                llvm::ArrayRef<mlir::LLVM::DINodeAttr>{} );

            funcOp->setAttr( "llvm.debug.subprogram", sub );

            // This is the key to ensure that translateModuleToLLVMIR does not strip the location info (instead
            // converts loc's into !dbg's)
            funcOp->setLoc( builder.getFusedLoc( { mod.getLoc() }, sub ) );

            subprogramAttr[funcName] = sub;
        }
    }

Those ‘1, 1’ parameters are line (first line of function declaration) and scopeLine (first line of code in the function body) respectively, and it takes a little bit of work to get both:

--- a/src/lowering.cpp
+++ b/src/lowering.cpp
@@ -411,9 +411,27 @@ namespace silly
 
             mlir::LLVM::DISubroutineTypeAttr subprogramType = createDISubroutineType( funcOp );
 
+            mlir::Location funcLoc = funcOp.getLoc();
+            mlir::FileLineColLoc loc = getLocation( funcLoc );
+            unsigned line = loc.getLine();
+            unsigned scopeLine = line;
+
+            mlir::Region ®ion = funcOp.getRegion();
+
+            mlir::Block &entryBlock = region.front();
+
+            // Get the location of the First operation in the block for the scopeLine:
+            if (!entryBlock.empty()) {
+              mlir::Operation *firstOp = &entryBlock.front();
+              mlir::Location firstLoc = firstOp->getLoc();
+              mlir::FileLineColLoc scopeLoc = getLocation( firstLoc );
+
+              scopeLine = scopeLoc.getLine();
+            }
+
             mlir::LLVM::DISubprogramAttr sub = mlir::LLVM::DISubprogramAttr::get(
                 context, mlir::DistinctAttr::create( builder.getUnitAttr() ), compileUnitAttr, fileAttr,
-                builder.getStringAttr( funcName ), builder.getStringAttr( funcName ), fileAttr, 1, 1,
+                builder.getStringAttr( funcName ), builder.getStringAttr( funcName ), fileAttr, line, scopeLine,
                 mlir::LLVM::DISubprogramFlags::Definition, subprogramType, llvm::ArrayRef{},
                 llvm::ArrayRef{} );

Here’s the new DWARF dump for bar0:

< 1><0x0000002a>    DW_TAG_subprogram
                      DW_AT_low_pc                0x00000000
                      DW_AT_high_pc                18 
                      DW_AT_frame_base            len 0x0001: 0x57: 
                          DW_OP_reg7
                      DW_AT_linkage_name          bar0
                      DW_AT_name                  bar0
                      DW_AT_decl_file             0x00000001 ./f.silly
                      DW_AT_decl_line             0x00000003
                      DW_AT_type                  <0x00000064> Refers to: void
                      DW_AT_external              yes(1)

scopeLine doesn’t show there, but it’s in the LLVM-IR dump:

!4 = distinct !DISubprogram(name: "bar0", linkageName: "bar0", scope: !1, file: !1, line: 3, type: !5, scopeLine: 3, spFlags: DISPFlagDefinition, unit: !0)

V6 of my silly MLIR based compiler is tagged.

December 28, 2025 clang/llvm , , , , , ,

Screenshot

There’s an existing toy MLIR dialect, part of the mlir tutorial documentation, so I’ve renamed my dialect from toy to silly, and updated all the references to ‘toy calculator’ to ‘silly compiler’, or ‘silly language’. There’s no good reason to use this language, nor the compiler, so this is very appropriate. It was, however, an excellent learning tool. The toy namespace is renamed, as are various file names, and all the MLIR operators, function prefixes, and so forth.

In addition to the big rename, other changes since the V5 tag include:

  1. A GET builtin (can now to I/O, not just O)
  2. FOR loop support.
  3. Something much closer to a consistent coding style now (FooBar for structures, fooBar for functions, no more use of all of PascalCase, camelCase, and underscore separated variables).
  4. Almost all of the auto variables have been purged for clarity.
  5. I’ve removed the ‘using namespace mlir’ in lowering.cpp.  Many of my mlir:: namespace references already had the namespace tag, so removing this allowed for more consistency.  I may revert this if it proves too cumbersome, but if I do, I’ll remove all the mlir:: qualifiers consistently (unless they are needed for disambiguation).
  6. User errors in the parser/builder no longer log the internal file:line:func for the code that spots them, but just the file:line location of the code with the error.  Those errors are now reported with mlir::emitError()
  7. Declarations in scf.for and scf.if/else regions are now supported.
  8. error test script now merged into bin/testit, so there’s just one script to run the regression test.
  9. Switched to /// style doxygen markup.

GET

Here’s a sample program with a GET call:

INT32 x;
GET x;
PRINT x;

and the corresponding MLIR output:

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %0 = silly.get : i32
      silly.assign @x = %0 : i32
      %1 = silly.load @x : i32
      silly.print %1 : i32
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

In the generated MLIR, I’ve split the GET builtin into an SSA for the get itself. In the example above, that’s returning the %0 value, and an internal AssignOp, kind of as if the statement was:

x = GET;

with the type information for the get riding on the assignment variable. That choice doesn’t model of the language in an ideal way. However, there are plenty of other places where my generated MLIR also isn’t a great one-to-one match for the language, so I don’t feel too bad about having done that, but might make different choices, if I wanted to have a lowering pass that transformed the silly dialect into something that represented a different language.

Here’s the corresponding LLVM-IR for that MLIR (with the DI stripped out)

declare void @__silly_print_i64(i64)

declare i32 @__silly_get_i32()

define i32 @main() !dbg !4 {
  %1 = alloca i32, i64 1, align 4
  %2 = call i32 @__silly_get_i32()
  store i32 %2, ptr %1, align 4
  %3 = load i32, ptr %1, align 4
  %4 = sext i32 %3 to i64
  call void @__silly_print_i64(i64 %4)
  ret i32 0
}

The use of the store/load pair that was related to the symbol references. There’s some remnant of that left in the assembly without optimization:

   0:   push   %rax
   1:   call   6 
                        2: R_X86_64_PLT32       __silly_get_i32-0x4
   6:   mov    %eax,0x4(%rsp)
   a:   movslq %eax,%rdi
   d:   call   12 
                        e: R_X86_64_PLT32       __silly_print_i64-0x4
  12:   xor    %eax,%eax
  14:   pop    %rcx
  15:   ret

but with optimization, we are left with everything in register:

   0:   push   %rax
   1:   call   6 
                        2: R_X86_64_PLT32       __silly_get_i32-0x4
   6:   movslq %eax,%rdi
   9:   call   e 
                        a: R_X86_64_PLT32       __silly_print_i64-0x4
   e:   xor    %eax,%eax
  10:   pop    %rcx
  11:   ret

FOR

Here’s a little FOR test program:

INT32 x;

FOR ( x : (1, 11) )
{
    PRINT x;
};

FOR ( x : (1, 11, 2) )
{
    PRINT x;
};

This prints 1-10 and 1,3,5,7,9 respectively. Here’s the MLIR (with location information stripped out):

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c1_i64 = arith.constant 1 : i64
      %0 = arith.trunci %c1_i64 : i64 to i32
      %c11_i64 = arith.constant 11 : i64
      %1 = arith.trunci %c11_i64 : i64 to i32
      %c1_i64_0 = arith.constant 1 : i64
      %2 = arith.trunci %c1_i64_0 : i64 to i32
      scf.for %arg0 = %0 to %1 step %2  : i32 {
        silly.assign @x = %arg0 : i32
        %6 = silly.load @x : i32
        silly.print %6 : i32
      }
      %c1_i64_1 = arith.constant 1 : i64
      %3 = arith.trunci %c1_i64_1 : i64 to i32
      %c11_i64_2 = arith.constant 11 : i64
      %4 = arith.trunci %c11_i64_2 : i64 to i32
      %c2_i64 = arith.constant 2 : i64
      %5 = arith.trunci %c2_i64 : i64 to i32
      scf.for %arg0 = %3 to %4 step %5  : i32 {
        silly.assign @x = %arg0 : i32
        %6 = silly.load @x : i32
        silly.print %6 : i32
      }
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

Observe that I did something sneaky in there: I’ve inserted a ‘silly.assign’ from the scf.for loop induction variable at the beginning of the loop, so that subsequent symbol based lookups just work. It would be cleaner to make the FOR loop variable private to the loop body (and have the builder reference the SSA induction variable directly forOp.getRegion().front().getArgument(0), instead of requiring a variable in the enclosing scope, but I did it this way to avoid the need for any additional dwarf instrumentation for that variable — basically, I was being lazy, and letting implementation guide the language “design”. Is that a hack? Absolutely!

Here’s the corresponding LLVM-IR:

declare void @__silly_print_i64(i64)

define i32 @main() { 
  %1 = alloca i32, i64 1, align 4
    #dbg_declare(ptr %1, !9, !DIExpression(), !8)
  br label %2

2:                                                ; preds = %5, %0
  %3 = phi i32 [ 1, %0 ], [ %8, %5 ]
  %4 = icmp slt i32 %3, 11
  br i1 %4, label %5, label %9

5:                                                ; preds = %2
  store i32 %3, ptr %1, align 4
  %6 = load i32, ptr %1, align 4
  %7 = sext i32 %6 to i64
  call void @__silly_print_i64(i64 %7)
  %8 = add i32 %3, 1
  br label %2

9:                                                ; preds = %2
  br label %10

10:                                               ; preds = %13, %9
  %11 = phi i32 [ 1, %9 ], [ %16, %13 ]
  %12 = icmp slt i32 %11, 11
  br i1 %12, label %13, label %17

13:                                               ; preds = %10
  store i32 %11, ptr %1, align 4
  %14 = load i32, ptr %1, align 4
  %15 = sext i32 %14 to i64
  call void @__silly_print_i64(i64 %15)
  %16 = add i32 %11, 2
  br label %10

17:                                               ; preds = %10
  ret i32 0

; uselistorder directives
  uselistorder ptr %1, { 2, 3, 0, 1 }
}

and the unoptimized codegen:

   0:   push   %rbx
   1:   sub    $0x10,%rsp
   5:   mov    $0x1,%ebx
   a:   cmp    $0xa,%ebx
   d:   jg     23 
   f:   nop
  10:   mov    %ebx,0xc(%rsp)
  14:   movslq %ebx,%rdi
  17:   call   1c 
                        18: R_X86_64_PLT32      __silly_print_i64-0x4
  1c:   inc    %ebx
  1e:   cmp    $0xa,%ebx
  21:   jle    10 
  23:   mov    $0x1,%ebx
  28:   cmp    $0xa,%ebx
  2b:   jg     44 
  2d:   nopl   (%rax)
  30:   mov    %ebx,0xc(%rsp)
  34:   movslq %ebx,%rdi
  37:   call   3c 
                        38: R_X86_64_PLT32      __silly_print_i64-0x4
  3c:   add    $0x2,%ebx
  3f:   cmp    $0xa,%ebx
  42:   jle    30 
  44:   xor    %eax,%eax
  46:   add    $0x10,%rsp
  4a:   pop    %rbx
  4b:   ret

At O2 optimization, the assembly printer chooses to unroll both loops completely, generating code like:

   0:   push   %rax
   1:   mov    $0x1,%edi
   6:   call   b 
                        7: R_X86_64_PLT32       __silly_print_i64-0x4
   b:   mov    $0x2,%edi
  10:   call   15 
                        11: R_X86_64_PLT32      __silly_print_i64-0x4
  15:   mov    $0x3,%edi
  1a:   call   1f 
                        1b: R_X86_64_PLT32      __silly_print_i64-0x4
  1f:   mov    $0x4,%edi
  24:   call   29 
                        25: R_X86_64_PLT32      __silly_print_i64-0x4
  29:   mov    $0x5,%edi
  2e:   call   33 
                        2f: R_X86_64_PLT32      __silly_print_i64-0x4
  33:   mov    $0x6,%edi
  38:   call   3d 
                        39: R_X86_64_PLT32      __silly_print_i64-0x4
...

SCF Region declarations

In the V5 tag of the compiler, a program like this wouldn’t work:

INT32 x;

x = 3;

IF ( x < 4 )
{
  INT32 y;
  y = 42;
  PRINT y;
};

PRINT "Done.";

This is because my DeclareOp needs to be in a region that has an associated symbol table (my ScopeOp). I've dealt with this by changing the insertion point for any declares to the beginning of the ScopeOp for the function (either the implicit main function, or a user defined function).

MLIR for the above program now looks like this:

module {
  func.func @main() -> i32 {
    "silly.scope"() ({
      "silly.declare"() <{type = i32}> {sym_name = "y"} : () -> ()
      "silly.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c3_i64 = arith.constant 3 : i64
      silly.assign @x = %c3_i64 : i64
      %0 = silly.load @x : i32
      %c4_i64 = arith.constant 4 : i64
      %1 = "silly.less"(%0, %c4_i64) : (i32, i64) -> i1
      scf.if %1 {
        %c42_i64 = arith.constant 42 : i64
        silly.assign @y = %c42_i64 : i64
        %3 = silly.load @y : i32
        silly.print %3 : i32
      }
      %2 = "silly.string_literal"() <{value = "Done."}> : () -> !llvm.ptr
      silly.print %2 : !llvm.ptr
      %c0_i32 = arith.constant 0 : i32
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

The declares for x, y, are no longer in the program order, but no program can observe that internal change, as I don't provide any explicit addressing operations.

Here's the generated LLVM-IR for this program:

@str_0 = private constant [5 x i8] c"Done."

declare void @__silly_print_string(i64, ptr)

declare void @__silly_print_i64(i64)

define i32 @main() !dbg !4 {
  %1 = alloca i32, i64 1, align 4
  %2 = alloca i32, i64 1, align 4
  store i32 3, ptr %2, align 4
  %3 = load i32, ptr %2, align 4
  %4 = sext i32 %3 to i64
  %5 = icmp slt i64 %4, 4
  br i1 %5, label %6, label %9

6:                                                ; preds = %0
  store i32 42, ptr %1, align 4
  %7 = load i32, ptr %1, align 4
  %8 = sext i32 %7 to i64
  call void @__silly_print_i64(i64 %8)
  br label %9

9:                                                ; preds = %6, %0
  call void @__silly_print_string(i64 5, ptr @str_0)
  ret i32 0
}

Without optimization, the codegen is:

   0:   push   %rax
   1:   movl   $0x3,(%rsp)
   8:   xor    %eax,%eax
   a:   test   %al,%al
   c:   jne    20 
   e:   movl   $0x2a,0x4(%rsp)
  16:   mov    $0x2a,%edi
  1b:   call   20 
                        1c: R_X86_64_PLT32      __silly_print_i64-0x4
  20:   mov    $0x5,%edi
  25:   mov    $0x0,%esi
                        26: R_X86_64_32 .rodata
  2a:   call   2f 
                        2b: R_X86_64_PLT32      __silly_print_string-0x4
  2f:   xor    %eax,%eax
  31:   pop    %rcx
  32:   ret

And with optimization, the branching on constant values is purged, leaving just gorp for the print calls:

   0:   push   %rax
   1:   mov    $0x2a,%edi
   6:   call   b 
                        7: R_X86_64_PLT32       __silly_print_i64-0x4
   b:   mov    $0x5,%edi
  10:   mov    $0x0,%esi
                        11: R_X86_64_32 .rodata
  15:   call   1a 
                        16: R_X86_64_PLT32      __silly_print_string-0x4
  1a:   xor    %eax,%eax
  1c:   pop    %rcx
  1d:   ret

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