silly dialect

Line stepping through MLIR with a debugger!

February 10, 2026 C/C++ development and debugging. , , , , ,

gdb session

I’ve added an alternate input source for the silly compiler.  As well as the .silly files that it previously accepted, it now also accepts .mlir (silly-dialect) files as input.

This means that if there’s an experimental language feature that requires new style MLIR, but I don’t want to figure out how to push that all the way through grammar -> parser -> builder -> lowering all at once, I might be able to at least understand the required MLIR patterns by by manually modifying exiting MLIR (generated with ‘silly –emit-mlir’).

For example, I don’t have BREAK support for FOR loops. I can do something simple:

INT64 v;

FOR (INT64 myLoopVar : (1, 5))
{
    PRINT myLoopVar;
    v = myLoopVar + 1;
};

PRINT "after loop: ", v;

The MLIR for this (with location info stripped out), looks like:

fedoravm:/home/peeter/toycalculator/tests/endtoend/for> silly-opt --pretty -s out/for_simplest.mlir 
module {
  func.func @main() -> i32 {
    %c0_i32 = arith.constant 0 : i32
    %c5_i64 = arith.constant 5 : i64
    %c1_i64 = arith.constant 1 : i64
    "silly.scope"() ({
      %0 = "silly.declare"() <{sym_name = "v"}> : () -> !silly.var
      scf.for %arg0 = %c1_i64 to %c5_i64 step %c1_i64  : i64 {
        "silly.print"(%c0_i32, %arg0) : (i32, i64) -> ()
        %3 = "silly.add"(%arg0, %c1_i64) : (i64, i64) -> i64
        silly.assign %0 :  = %3 : i64
      }
      %1 = "silly.string_literal"() <{value = "after loop: "}> : () -> !llvm.ptr
      %2 = silly.load %0 :  : i64
      "silly.print"(%c0_i32, %1, %2) : (i32, !llvm.ptr, i64) -> ()
      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

If I want to add a BREAK into the mix (which I don’t support in any of grammar or parser or builder right now), something like:

INT64 v; 
FOR (INT64 i : (1, 5)) {
    PRINT i; 
    v = i + 1; 
    IF (i == 3) { BREAK; }; 
};
PRINT "after loop: ", v; 

Then it can be done by replacing the scf.for with scf.while, and putting in additional termination condition logic. Example:

module {
  func.func @main() -> i32 {
    %c0_i32 = arith.constant 0 : i32
    %c1_i64 = arith.constant 1 : i64
    %c3_i64 = arith.constant 3 : i64
    %c5_i64 = arith.constant 5 : i64
    %true = arith.constant true
    %false = arith.constant false

    "silly.scope"() ({
      %0 = "silly.declare"() <{sym_name = "v"}> : () -> !silly.var

      scf.while (%i = %c1_i64, %broke = %false) : (i64, i1) -> (i64, i1) {
        %not_broke = arith.xori %broke, %true : i1
        %in_range = arith.cmpi slt, %i, %c5_i64 : i64
        %continue = arith.andi %in_range, %not_broke : i1
        scf.condition(%continue) %i, %broke : i64, i1
      } do {
      ^bb0(%loop_var: i64, %break_flag: i1):
        "silly.print"(%c0_i32, %loop_var) : (i32, i64) -> ()
        %2 = "silly.add"(%loop_var, %c1_i64) : (i64, i64) -> i64
        silly.assign %0 :  = %2 : i64

        %is_three = arith.cmpi eq, %loop_var, %c3_i64 : i64
        %should_break = arith.ori %break_flag, %is_three : i1

        %next = arith.addi %loop_var, %c1_i64 : i64
        scf.yield %next, %should_break : i64, i1
      }

      %lit = "silly.string_literal"() <{value = "after loop: "}> : () -> !llvm.ptr
      %p = silly.load %0 :  : i64
      "silly.print"(%c0_i32, %lit, %p) : (i32, !llvm.ptr, i64) -> ()

      "silly.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "silly.yield"() : () -> ()
  }
}

Now, here’s where things get cool.  I noticed something curious when I looked at the .mlir dump from the MLIR parser (which I dumped to verify I was getting the expected round trip output before lowering). The MLIR parser, given only MLIR source, and no other location tagging, goes off and tags everything with location info for the MLIR source itself.  Example:

#loc15 = loc("forbreak.mlsilly":27:12)
#loc16 = loc("forbreak.mlsilly":27:28)
module {
  func.func @main() -> i32 {
    %c0_i32 = arith.constant 0 : i32 loc(#loc2)
    %c1_i64 = arith.constant 1 : i64 loc(#loc3)
    %c3_i64 = arith.constant 3 : i64 loc(#loc4)
    %c5_i64 = arith.constant 5 : i64 loc(#loc5)
    %true = arith.constant true loc(#loc6)
    %false = arith.constant false loc(#loc7)
    "silly.scope"() ({
      %0 = "silly.declare"() <{sym_name = "v"}> : () -> !silly.var loc(#loc9)
      %1:2 = scf.while (%arg0 = %c1_i64, %arg1 = %false) : (i64, i1) -> (i64, i1) {
        %4 = arith.xori %arg1, %true : i1 loc(#loc11)
        %5 = arith.cmpi slt, %arg0, %c5_i64 : i64 loc(#loc12)
        %6 = arith.andi %5, %4 : i1 loc(#loc13)
        scf.condition(%6) %arg0, %arg1 : i64, i1 loc(#loc14)
      } do {
      ^bb0(%arg0: i64 loc("forbreak.mlsilly":27:12), %arg1: i1 loc("forbreak.mlsilly":27:28)):
        "silly.print"(%c0_i32, %arg0) : (i32, i64) -> () loc(#loc17)
        %4 = "silly.add"(%arg0, %c1_i64) : (i64, i64) -> i64 loc(#loc18)
        silly.assign %0 :  = %4 : i64 loc(#loc19)
        %5 = arith.cmpi eq, %arg0, %c3_i64 : i64 loc(#loc20)
        %6 = arith.ori %arg1, %5 : i1 loc(#loc21)
        %7 = arith.addi %arg0, %c1_i64 : i64 loc(#loc22)
        scf.yield %7, %6 : i64, i1 loc(#loc23)
      } loc(#loc10)
      %2 = "silly.string_literal"() <{value = "after loop: "}> : () -> !llvm.ptr loc(#loc24)
      %3 = silly.load %0 :  : i64 loc(#loc25)
      "silly.print"(%c0_i32, %2, %3) : (i32, !llvm.ptr, i64) -> () loc(#loc26)
      "silly.return"(%c0_i32) : (i32) -> () loc(#loc27)
    }) : () -> () loc(#loc8)
    "silly.yield"() : () -> () loc(#loc28)
  } loc(#loc1)
} loc(#loc)
#loc = loc("forbreak.mlsilly":9:1)
#loc1 = loc("forbreak.mlsilly":10:3)
#loc2 = loc("forbreak.mlsilly":11:15)
#loc3 = loc("forbreak.mlsilly":12:15)
#loc4 = loc("forbreak.mlsilly":13:15)
#loc5 = loc("forbreak.mlsilly":14:15)
#loc6 = loc("forbreak.mlsilly":15:13)
#loc7 = loc("forbreak.mlsilly":16:14)
...

My compiler can then turns that location info into dwarf DI, just as it does for regular .silly source file, so I can actually line step through the MLIR itself with any debugger! Here’s an example session:

Breakpoint 1, main () at forbreak.mlsilly:25
25              scf.condition(%continue) %i, %broke : i64, i1
(gdb) l
20            
21            scf.while (%i = %c1_i64, %broke = %false) : (i64, i1) -> (i64, i1) {
22              %not_broke = arith.xori %broke, %true : i1
23              %in_range = arith.cmpi slt, %i, %c5_i64 : i64
24              %continue = arith.andi %in_range, %not_broke : i1
25              scf.condition(%continue) %i, %broke : i64, i1
26            } do {
27            ^bb0(%loop_var: i64, %break_flag: i1):
28              "silly.print"(%c0_i32, %loop_var) : (i32, i64) -> ()
29              %2 = "silly.add"(%loop_var, %c1_i64) : (i64, i64) -> i64
(gdb) l
30              silly.assign %0 :  = %2 : i64
31              
32              %is_three = arith.cmpi eq, %loop_var, %c3_i64 : i64
33              %should_break = arith.ori %break_flag, %is_three : i1
34              
35              %next = arith.addi %loop_var, %c1_i64 : i64
36              scf.yield %next, %should_break : i64, i1
37            }
38
39            %lit = "silly.string_literal"() <{value = "after loop: "}> : () -> !llvm.ptr
(gdb) b 32
Breakpoint 2 at 0x40076c: file forbreak.mlsilly, line 32.
(gdb) c
Continuing.
1

Breakpoint 2, main () at forbreak.mlsilly:32
32              %is_three = arith.cmpi eq, %loop_var, %c3_i64 : i64
(gdb) disassemble
Dump of assembler code for function main:
   0x000000000040072c <+0>:     sub     sp, sp, #0x60
   0x0000000000400730 <+4>:     stp     x30, x21, [sp, #64]
   0x0000000000400734 <+8>:     stp     x20, x19, [sp, #80]
   0x0000000000400738 <+12>:    mov     w19, wzr
   0x000000000040073c <+16>:    mov     w20, #0x1                       // #1
   0x0000000000400740 <+20>:    mov     w21, #0x1                       // #1
   0x0000000000400744 <+24>:    str     xzr, [sp, #8]
   0x0000000000400748 <+28>:    cmp     x21, #0x4
   0x000000000040074c <+32>:    b.gt    0x400784 
   0x0000000000400750 <+36>:    tbnz    w19, #0, 0x400784 
   0x0000000000400754 <+40>:    add     x1, sp, #0x10
   0x0000000000400758 <+44>:    mov     w0, #0x1                        // #1
   0x000000000040075c <+48>:    stp     x21, xzr, [sp, #24]
   0x0000000000400760 <+52>:    str     x20, [sp, #16]
   0x0000000000400764 <+56>:    bl      0x4005b0 <__silly_print@plt>
   0x0000000000400768 <+60>:    add     x21, x21, #0x1
=> 0x000000000040076c <+64>:    cmp     x21, #0x4
   0x0000000000400770 <+68>:    str     x21, [sp, #8]
   0x0000000000400774 <+72>:    cset    w8, eq  // eq = none
   0x0000000000400778 <+76>:    orr     w19, w19, w8
   0x000000000040077c <+80>:    cmp     x21, #0x4
   0x0000000000400780 <+84>:    b.le    0x400750 
   0x0000000000400784 <+88>:    mov     x8, #0x3                        // #3
   0x0000000000400788 <+92>:    ldr     x9, [sp, #8]
   0x000000000040078c <+96>:    mov     w10, #0xc                       // #12
   0x0000000000400790 <+100>:   movk    x8, #0x1, lsl #32
   0x0000000000400794 <+104>:   add     x1, sp, #0x10
   0x0000000000400798 <+108>:   mov     w0, #0x2                        // #2
   0x000000000040079c <+112>:   stp     x8, x10, [sp, #16]
   0x00000000004007a0 <+116>:   adrp    x8, 0x400000
   0x00000000004007a4 <+120>:   add     x8, x8, #0x7f8
   0x00000000004007a8 <+124>:   stp     x9, xzr, [sp, #48]
   0x00000000004007ac <+128>:   mov     w9, #0x1                        // #1
   0x00000000004007b0 <+132>:   stp     x8, x9, [sp, #32]
   0x00000000004007b4 <+136>:   bl      0x4005b0 <__silly_print@plt>
   0x00000000004007b8 <+140>:   ldp     x20, x19, [sp, #80]
   0x00000000004007bc <+144>:   mov     w0, wzr
   0x00000000004007c0 <+148>:   ldp     x30, x21, [sp, #64]
   0x00000000004007c4 <+152>:   add     sp, sp, #0x60
   0x00000000004007c8 <+156>:   ret
End of assembler dump.



(gdb) c
Continuing.
2

Breakpoint 2, main () at forbreak.mlsilly:32
32              %is_three = arith.cmpi eq, %loop_var, %c3_i64 : i64
(gdb) p v
$2 = 2

Having built a compiler for an arbitrary language, and having implemented DWARF instrumentation for that language, I get line support for stepping through the MLIR itself, if I want it.

I can imagine a scenerio where I’ve screwed up the MLIR ops generation in the builder. This lets me set a breakpoint right at the MLIR line in question, and poke around at the disassembly for that point in the code, and see what’s going on. What a cool compiler debugging tool!

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