{"article":{"slug":"chasing-a-o2-pessimization-in-antcc","title":"Chasing a -O2 pessimization in antcc","subtitle":null,"summary":"The author of the antcc C compiler investigates why a Lua benchmark ran slower at -O1 and -O2 than unoptimized, digging into the interpreter dispatch loop, register allocation, SSA phi handling and stack slots to find and fix the optimization that made the code worse.","content_type":"blog_post","language":"en","canonical_url":"https://386s.org/w/lua-antcc-pessimization/","author":{"name":null,"url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"386s.org","url":"https://386s.org/","listing_slug":null,"listing":null},"topics":[{"name":"C","slug":"c","url":"https://listedarticles.com/topics/c"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"},{"name":"Systems Programming","slug":"systems-programming","url":"https://listedarticles.com/topics/systems-programming"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2750,"reading_minutes":12,"published_at":"2026-10-12T00:00:00.000Z","added_at":"2026-10-13T14:26:42.019Z","updated_at":"2026-10-13T14:26:42.019Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/chasing-a-o2-pessimization-in-antcc","markdown_url":"https://listedarticles.com/articles/chasing-a-o2-pessimization-in-antcc.md","example":false,"citation":"386s.org. \"Chasing a -O2 pessimization in antcc.\" 12 Oct 2026. https://386s.org/w/lua-antcc-pessimization/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://386s.org/w/lua-antcc-pessimization/"},"body_markdown":"Recently I was benchmarking the generated code efficiency for my C compiler\n[antcc](https://codeberg.org/lsof/antcc/), and I noticed something concerning in a simple Lua benchmark:\n\n```\nbenchmark    opt       runtime\n lua                    1.048s\n lua         -O1        1.077s\n lua         -O2        1.410s\n```\nThe \"optimized\" builds were actually slower. Now, my compiler doesn't implement\nmany optimizations at the moment so I wouldn't expect a huge improvement, but\nbeing 1.4x slower should definitely not happen.<sup>[\\[1\\]](https://386s.org/w/lua-antcc-pessimization/#fn-1)</sup> One of the optimizations\nthat antcc *does* have is inlining, which generally speaking is one of the most\nimpactful compiler optimizations and it usually improves benchmarks all around.\n\nSo my first thought was, okay my inlining heuristics and thresholds are very simple and not really tuned, so perhaps it's being too aggressive and blowing up the code size, leading to very bad instruction cache thrashing. If I disabled inlining, the pessimization seemed to go away, which could support that theory. That's not a very satisfactory explanation though, and I would like to fix it, so I looked into it more.\n\nThe main culprit was in the function `luaV_execute`, whose code blew up to\n~43KB with -O2, **~2.4x** bigger than the -O1 build. Now, [this function](https://github.com/lua/lua/blob/c33b1728aeb7dfeec4013562660e07d32697aa6b/lvm.c#L1127)\nis the main bytecode interpreter loop for the Lua VM, and these are one of the\nworst kinds of functions for a compiler to deal with: it's a huge function with\na hot loop, many cases/control flow branches, distinct hot/cold paths nested in\nthe graph where a wrong optimizer or register-allocation decision can pessimize\nthe whole loop, etc...<sup>[\\[2\\]](https://386s.org/w/lua-antcc-pessimization/#fn-2)</sup> So the baseline function is a tricky one, but it\ndoesn't explain this.\n\nTweaking the inlining thresholds didn't seem to help: either few things got\ninlined (like -O1) and binary size & performance stayed roughly the same, or\nsmall functions start getting inlined and the pessimization happens. In\nparticular, these functions were getting inlined (some in multiple instances):\n`floatforloop`, `lessequalothers`, `lessthanothers`, `luaV_idiv`, `luaV_mod`,\n`luaV_modf`, `luaV_shiftl`, `luaV_tointegerns`, plus some of their respective\ncallees.  In the Lua source code link above you can see those are all pretty\nsmall simple functions.\n\nLooking at the final codegen is not that helpful, but examining the IR<sup>[\\[3\\]](https://386s.org/w/lua-antcc-pessimization/#fn-3)</sup>\nrevealed something interesting: after all optimizations, the -O2 IR was **only\n11% bigger** than -O1. How could that be amplified to a **2.4x** difference in\nthe binary?\n\nThe final passes that run after optimizations and before code generation are\n[instruction selection](https://en.wikipedia.org/wiki/Instruction_selection) and [register allocation](https://en.wikipedia.org/wiki/Register_allocation). Instruction selection\nmostly concerns itself with ensuring instruction operands are ready to be\nlowered into machine code, but keeps temporaries as virtual registers. For\nexample, for x86-64, it models memory operands and will turn a sequence of IR\ninstructions like:\n\n```\n;; int *ptr, res;\ni64 %t2 = shl %index, 2\nptr %t3 = add %ptr, %t2\n;; res = ptr[index]\ni32 %res = loads32 %t3\n```\ninto\n\n`i32 %res = loads32 addr [%ptr + %index * 4]`\nIt does other things like introducing register constraints for certain operations, but anyway because it only expands into small sequences of instructions like this, it couldn't be the cause.\n\nRegister allocation is the process of mapping virtual temporaries to the target architecture's physical registers. For the previous snippet, that might result in\n\n`i32 RAX = loads32 addr [RSI + RDX * 4]`\nwhich is compiled to the x86 instruction `mov eax, DWORD PTR [rsi + rdx * 4]`.\n\nThe problem is that there are infinite virtual temporaries but a small number of registers,\nso you have to choose which variables get which registers and when there are not enough\nregisters for all of them, some of them have to go on the stack, which is called *spilling*.\n\nMost operations require values to be in registers, so spilled variables often have to be moved back and forth between registers. Or even worse, they might have to be moved between two different stack slots in some situations.\n\nantcc has a basic implementation of [linear scan regalloc](https://c9x.me/compile/bib/Wimmer10a.pdf). If there is\na lot of register pressure and many temporaries get spilled, potentially a lot\nof instructions can be created just to shuffle values between stack and\nregisters.\n\nAnd indeed, comparing relative IR size increase before and after regalloc at -O2, the +11%\ndifference was turning into +70% blowup after regalloc... looking at the disassembly,\n**>40%** of instructions in `luaV_execute` were just stack⇄register `mov`s. That's really bad,\nsomething being inlined must be resulting in this dreadful register pressure and spilling,\nbut it's not obvious what.\n\nSo let's examine the pre-regalloc IR:\n\n```\n<< Before regalloc >>\nfunction luaV_execute : void(struct lua_State *, struct CallInfo *)\nabi: (RDI, RSI)\n   ....\n    ptr %4613 = add %0, 56\n    ptr %5340 = add %0, 40\n    b @2\n  @2:   ; preds: @1, @892       ; loop depth: 1\n    i64 %4489 = phi @1 undef, @892 %3124\n    i64 %3030 = phi @1 undef, @892 %3065\n    i64 %4636 = phi @1 undef, @892 %2287\n    i64 %4764 = phi @1 undef, @892 %3869\n    i64 %2874 = phi @1 undef, @892 %3929\n    i64 %2694 = phi @1 undef, @892 %3981\n    i64 %4872 = phi @1 undef, @892 %3804\n    i64 %2297 = phi @1 undef, @892 %3497\n    i64 %5062 = phi @1 undef, @892 %3158\n    i64 %2208 = phi @1 undef, @892 %3145\n    i64 %2161 = phi @1 undef, @892 %4191\n    i64 %5275 = phi @1 undef, @892 %4225\n    i64 %5290 = phi @1 undef, @892 %4252\n    i64 %1791 = phi @1 undef, @892 %4370\n    i64 %3813 = phi @1 undef, @892 %3195\n    i64 %3807 = phi @1 undef, @892 %4403\n  ....\n```\nOh. That looks strange.\n\nMy compiler like most modern backends uses a [Static Single-Assignment](https://en.wikipedia.org/wiki/Static_single-assignment_form) representation.\nIn this form, straight-line code such as (I'm using C-like pseudocode to simplify):\n\n```\nx = a + b\nx = x + y    # reassigning x\nuse(x)\n```\nBecomes:\n\n```\nx1 = a + b\nx2 = x1 + y  # fresh definition\nuse(x2)\n```\nThat is, there are no variable mutation instructions. The reason this representation is great for compilers is that it simplifies all kinds of data-flow analysis and permits some more powerful optimizations than the non-SSA counterpart.\n\nWith control flow, data flow merges are explicit in the IR:\n\n```\nif (cond) {\n    x = a + 1\n} else {\n    x = b - 1\n}\nuse(x)\n```\nIn SSA form is represented as:\n\n```\nif (cond) B0: {\n    x1 = a + 1\n} else B1: {\n    x2 = b - 1\n}\nx3 = phi(B0: x1, B1: x2)\nuse(x3)\n```\nThis magic \"phi\" instruction<sup>[\\[4\\]](https://386s.org/w/lua-antcc-pessimization/#fn-4)</sup> has as its result the value of `x1` or `x2` depending on which\ncontrol flow branch was taken. Same approach can be applied for variables modified in loops:\n\n```\ni = 0\nwhile (cond) {\n    use(i)\n    i += 1\n}\n```\nin SSA:\n\n```\ni0 = 0\nEntry: while (cond) {\n  LoopBegin:\n    i_l = phi(Entry: i0, LoopEnd: i_n)\n    use(i_l)\n    i_n = i_l + 1\n  LoopEnd:\n}\n```\nTextually, it looks like the `phi` function references `i_n` before its definition, but\nimportantly that value is only taken in the loop back-edge (`LoopEnd -> LoopBegin`),\nand at that point the value of `i_n` is available.\n\nA key thing to realize here is that this `i_l` temporary, being a loop phi, is live\nacross the whole loop, since its value is used to compute the next iteration.\n\nGoing back to that IR dump from Lua:\n\n```\n@2:   ; preds: @1, @892       ; loop depth: 1\n  i64 %4489 = phi @1 undef, @892 %3124\n  i64 %3030 = phi @1 undef, @892 %3065\n  ...\n```\nIgnore the undefs for now, and notice the `; preds` line indicating the\npredecessors of that block: `@1`, the beginning of the function, and `@892`,\nhundreds of blocks later: the end of the main interpreter loop. Meaning, we\nhave 16 phis that are live across the whole loop. This is what is creating a\nlot of register pressure, wrecking register allocation and resulting in that\nbig code size blowup from stack spilling traffic. So, what are these phis,\nwhere do they come from, and why the `undef`s...\n\nC programs don't start in SSA form, so they have to go through a transformation pass. I borrowed the terminology and design from clang/LLVM in my compiler:\n\n1. Variables always start as virtual stack slots, defined with an `alloca` pseudo-instruction.<sup>[\\[5\\]](https://386s.org/w/lua-antcc-pessimization/#fn-5)</sup>  - Since local variables in C can have their address taken<sup>[\\[6\\]](https://386s.org/w/lua-antcc-pessimization/#fn-6)</sup> , this\ndirectly models the C abstract machine's definition of`automatic variables` : a physical addressable stack slot that can be read from\nor written to.\n2. Since local variables in C can have their address taken\n3. Variable reads turn into memory `load` instructions, and writes turn into`store` instructions.\n4. Later, the `mem2reg` pass collects stack`alloca` s whose address\ndoes not escape (meaning they are only used for direct reads and writes\nthat the compiler can observe), and \"promotes\" them into SSA temporaries,\ninserting phi instructions where necessary.\n\nSo C code like:\n\n```\nint x;\nx = foo();\n...\nif (x < 0) { x = -x; }\nreturn x;\n```\nInitially generates an IR that looks like:\n\n```\n@1:\n  ptr %x = alloca 4 bytes\n  i32 %t1 = call foo()\n  store i32 %x, %t1\n  ...\n  i32 %t2 = load i32 %x\n  bool %cond = lth %t2, 0\n  br %cond, true -> @2, false -> @3\n@2:\n  i32 %t3 = load i32 %x\n  i32 %t4 = neg %t3\n  store i32 %x, %t4\n  b @3\n@3:\n  i32 %t5 = load i32 %x\n  ret %t5\n```\nAnd mem2reg turns it to:\n\n```\n@1:\n  i32 %x1 = call foo()\n  ...\n  bool %cond = lth %x1, 0\n  br %cond, true -> @2, false -> @3\n@2:\n  i32 %x2 = neg %x1\n  b @3\n@3:\n  i32 %x3 = phi @1 %x1, @2 %x2\n  ret %x3\n```\nI'm glossing over the trickiest part of this, which is knowing when and where\nto insert these phi instructions in the general case. There's basically two\nalgorithms to do it, and the one I'm using is from the paper [\"Simple and\nEfficient Construction of SSA Form (Braun et al, 2013)\"](https://pp.ipd.kit.edu/uploads/publikationen/braun13cc.pdf).  The gist of\nit is: walk the control-flow graph (CFG) in reverse post-order (RPO); when you\nsee a write, memoize the current definition keyed by the block; when you see a read,\nlook for that definition or query the predecessors' definitions recursively and\ninsert phis when that lookup finds distinct definitions. Loops are trickier:\nbecause the back-edge predecessors haven't been visited yet, you have to insert\na provisional phi and resolve it later.\n\nSomething about C that almost every language that came after it removed as a Bad Idea is the ability to leave variables uninitialized. For example:\n\n```\nint x;\nif (foo()) {\n    int y = bar();\n    x = y;\n}\nuse(x);\n```\nAfter the control flow merge, `x` is `phi(uninitialized, y)`. That is what\nthe `undef` in my IR is. The way it's introduced in my mem2reg is that if the\nrecursive \"read current definition\" function reaches all the way to the\nbeginning of the function without finding a definition, it gives it `undef`.\n(Later on, moves and operations on `undef` can be effectively optimized away.)\n\nSo was the Lua interpreter reading uninitialized variables? Well, no. Look at some of the affected code from the interpreter function:\n\n```\n  vmcase(OP_SHRI) {\n    TValue *rb = vRB(i);\n    int ic = GETARG_sC(i);\n    lua_Integer ib;\n    if (tointegerns(rb, &ib)) {\n      pc++; setivalue(s2v(ra), luaV_shiftl(ib, -ic));\n    }\n    vmbreak;\n  }\n  // several other instructions follow that same template\n```\nThe key is the `tointegerns` macro, which in turn calls the function `luaV_tointegerns`:\n\n```\nint luaV_tointegerns (const TValue *obj, lua_Integer *p, F2Imod mode) {\n  if (ttisfloat(obj))\n    return luaV_flttointeger(fltvalue(obj), p, mode); // <- this gets inlined\n  else if (ttisinteger(obj)) {\n    *p = ivalue(obj);\n    return 1;\n  }\n  else\n    return 0;\n}\n```\nIn -O2, this function and `luaV_flttointeger` get inlined, resulting in an\nexpansion like this (simplified):\n\n```\n  vmcase(OP_SHRI) {\n    ...\n    lua_Integer ib;   // <--- declaration\n    int tointegerns_ret;\n    if (ttisinteger(rb)) {\n      ib = ivalue(rb);   // <--- write\n      tointegerns_ret = 1;\n    } else if (ttisfloat(rb)) {\n      ...\n      ib = (lua_Integer)fvalue(f);    // <--- write\n      tointegerns_ret = 1;\n    } else {\n      tointegerns_ret = 0;\n    }\n    if (tointegerns_ret) {\n      ... (use ib)\n    }\n    vmbreak;\n  }\n```\n`mem2reg` is always ran after every inlining pass. The `undef` in question\ncomes from the variable `ib` which I've marked. You can see that the local\nvariable is initialized in every path where the inlined function returns `1`,\nbut `mem2reg` cannot correlate that with the later `if (tointegerns_ret)` guard,\nso it sees a possibly uninitialized read.\n\nBut that does *not* tell the whole story! It explains the `undef`, but not why\nthe phi is live across the whole loop. After all, this local is scoped to a\nblock inside a branch of the loop, it does not live across loop iterations...\n\nEventually I figured out the root cause: I was treating the idea of \"variables\nstart as uninitialized\" *too* literally in mem2reg. When it encountered an\n`alloca`, it didn't give it a definition for the current block. This sounds\ncorrect, and the recursive lookup in the algorithm terminates at the function\nentry with an `undef`. But recall the catch with loops being that when you\nencounter a backedge, you don't know if there's a later definition, because you\nhaven't visited the whole loop yet, so you insert a provisional phi...\n\nIn essence, my mem2reg was behaving as if code like:\n\n```\nint foo() {\n  while (...) {\n    int x;\n    if (...) {\n      x = bar();\n    }\n    use(x);\n  }\n}\n```\nwas actually\n\n```\nint foo() {\n  int x;\n  while (...) {\n    if (...) {\n      x = bar();\n    }\n    use(x);\n  }\n}\n```\nIn the 2nd function, the value of `x` is kept across iterations, whereas in the\n1st, the value of `x` is a 'fresh' uninitialized every time the `if` branch is\nnot taken...  This is not a miscompilation: as far as C is concerned, this is\nfine because uninitialized variables' values are not legally usable anyway. And\nif you frame it in terms of stack-allocated variables, it's a sensible\napproach: the slot for x is fixed for the whole function and doesn't get\nreset between loop iterations, so it keeps the previous iteration's whatever\nit was and it doesn't matter. But the problem is that in the context of SSA\nform, this generates something like:\n\n```\nwhile (...) {\n  %x = phi(undef, %x2)\n  if (...) {\n    %x1 = bar();\n  }\n  %x2 = phi(%x, %x1)\n  use(%x2);\n}\n```\nAnd it is these loop phis that result in the pathological register allocation. The fix was one line:\n\n```\n--- a/src/ir_mem2reg.c\n+++ b/src/ir_mem2reg.c\n@@ -244,6 +244,8 @@ mem2reg(Function *fn) {\n             v->cls = k;\n             v->i = var;\n             sb.vars[var] = v;\n+            v->curdefs[blk->id] = UNDREF;\n          } else {\n             sb.vars[var] = DEAD;\n          }\n```\nInitializing the var as undefined (a nice oxymoron) at the place of the\n`alloca` ensures that the recursive lookup doesn't cross loop backedges and\ninsert phis in the loop header. This is enough to fix this case, and that\nchange made the mysterious pessimization disappear.\n\nThis problem can still bite for C code that is old-style declaring all\nvariables at the top of the function. It's possible to fix this general case by\nusing [Tarjan's algorithm](https://en.wikipedia.org/wiki/Tarjan's_strongly_connected_components_algorithm) to find the graph SCCs of the phi data-flow to\ndetect cycles, and making use of these undef semantics when classifying\nphis as redundant; but I haven't implemented that yet.\n\nJump threading an inlined function used directly as a branch condition, or more\ngenerally SSA-aware phi jump threading, could've also fixed the branch\ncorrelation issue that led to `mem2reg` not seeing the dominating write of\nthose variables and introducing undefs.\n\nI made use of AI agents to help debug this issue. It saved me a lot of time in investigating, filtering through disassemblies and IR dumps, running the compiler with different inlining thresholds and measuring, etc; but it was not good at trying to find the root cause. It proposed probably around 10 convoluted \"fixes\" before I eventually arrived at the conclusion myself. Still <$1 well spent for the sake of reducing tedium.\n\nLua 5.4.7, [bench.lua](https://386s.org/asset/202610-antcc-bench.lua), AMD Ryzen 5 5600G. Take\nthese with a grain of salt.\n\nPre-fix:\n\nantcc was worse than even tcc, but that's mainly because my frontend was lowering switch statements to linear if-else chains. I implemented a simple binary search codegen, which was one of those things that I just hadn't gotten around to yet; and a few other minor optimizations; after this fix.\n\nCurrent numbers:\n\nDo note that antcc performs `mem2reg` by default when passed no optimization\nflags, unlike gcc. It might seem counterintuitive, but from my testing, running\n`mem2reg` improves compile times too; presumably due to the reduced number of\nstack store/load instructions for the backend to churn through.\n\nAnd compile times:\n\nI omitted the bars for clang because they are all very similar to gcc.\n\n1. \nThe only difference between -O1 and -O2 is in inlining thresholds and -O2 running some optimization passes more than once. [↩](https://386s.org/w/lua-antcc-pessimization/#fr-1-1)\n2. \nThis is why LuaJIT's interpreter in hand-written assembly beats almost every other bytecode interpreter, [and it's not just about the while-switch loop](http://lua-users.org/lists/lua-l/2011-02/msg00742.html)[↩](https://386s.org/w/lua-antcc-pessimization/#fr-2-1)\n3. \nIntermediate representation [↩](https://386s.org/w/lua-antcc-pessimization/#fr-3-1)\n4. \nvariously known as phi function, φ, /faɪ/, /fi/, phony instruction [↩](https://386s.org/w/lua-antcc-pessimization/#fr-4-1)\n5. \nNot to be confused with the dynamic `alloca()` function.[↩](https://386s.org/w/lua-antcc-pessimization/#fr-5-1)\n6. \nThe `register` storage class exists, but it doesn't really matter in\nmodern architectures so you (almost) never see it used.[↩](https://386s.org/w/lua-antcc-pessimization/#fr-6-1)","body_html":"<p>Recently I was benchmarking the generated code efficiency for my C compiler\n<a href=\"https://codeberg.org/lsof/antcc/\" rel=\"nofollow ugc noopener\">antcc</a>, and I noticed something concerning in a simple Lua benchmark:</p>\n<pre><code>benchmark    opt       runtime\n lua                    1.048s\n lua         -O1        1.077s\n lua         -O2        1.410s</code></pre>\n<p>The &quot;optimized&quot; builds were actually slower. Now, my compiler doesn&#39;t implement\nmany optimizations at the moment so I wouldn&#39;t expect a huge improvement, but\nbeing 1.4x slower should definitely not happen.&lt;sup&gt;<a href=\"https://386s.org/w/lua-antcc-pessimization/#fn-1\" rel=\"nofollow ugc noopener\">[1]</a>&lt;/sup&gt; One of the optimizations\nthat antcc <em>does</em> have is inlining, which generally speaking is one of the most\nimpactful compiler optimizations and it usually improves benchmarks all around.</p>\n<p>So my first thought was, okay my inlining heuristics and thresholds are very simple and not really tuned, so perhaps it&#39;s being too aggressive and blowing up the code size, leading to very bad instruction cache thrashing. If I disabled inlining, the pessimization seemed to go away, which could support that theory. That&#39;s not a very satisfactory explanation though, and I would like to fix it, so I looked into it more.</p>\n<p>The main culprit was in the function <code>luaV_execute</code>, whose code blew up to\n~43KB with -O2, <strong>~2.4x</strong> bigger than the -O1 build. Now, <a href=\"https://github.com/lua/lua/blob/c33b1728aeb7dfeec4013562660e07d32697aa6b/lvm.c#L1127\" rel=\"nofollow ugc noopener\">this function</a>\nis the main bytecode interpreter loop for the Lua VM, and these are one of the\nworst kinds of functions for a compiler to deal with: it&#39;s a huge function with\na hot loop, many cases/control flow branches, distinct hot/cold paths nested in\nthe graph where a wrong optimizer or register-allocation decision can pessimize\nthe whole loop, etc...&lt;sup&gt;<a href=\"https://386s.org/w/lua-antcc-pessimization/#fn-2\" rel=\"nofollow ugc noopener\">[2]</a>&lt;/sup&gt; So the baseline function is a tricky one, but it\ndoesn&#39;t explain this.</p>\n<p>Tweaking the inlining thresholds didn&#39;t seem to help: either few things got\ninlined (like -O1) and binary size &amp; performance stayed roughly the same, or\nsmall functions start getting inlined and the pessimization happens. In\nparticular, these functions were getting inlined (some in multiple instances):\n<code>floatforloop</code>, <code>lessequalothers</code>, <code>lessthanothers</code>, <code>luaV_idiv</code>, <code>luaV_mod</code>,\n<code>luaV_modf</code>, <code>luaV_shiftl</code>, <code>luaV_tointegerns</code>, plus some of their respective\ncallees.  In the Lua source code link above you can see those are all pretty\nsmall simple functions.</p>\n<p>Looking at the final codegen is not that helpful, but examining the IR&lt;sup&gt;<a href=\"https://386s.org/w/lua-antcc-pessimization/#fn-3\" rel=\"nofollow ugc noopener\">[3]</a>&lt;/sup&gt;\nrevealed something interesting: after all optimizations, the -O2 IR was <strong>only\n11% bigger</strong> than -O1. How could that be amplified to a <strong>2.4x</strong> difference in\nthe binary?</p>\n<p>The final passes that run after optimizations and before code generation are\n<a href=\"https://en.wikipedia.org/wiki/Instruction_selection\" rel=\"nofollow ugc noopener\">instruction selection</a> and <a href=\"https://en.wikipedia.org/wiki/Register_allocation\" rel=\"nofollow ugc noopener\">register allocation</a>. Instruction selection\nmostly concerns itself with ensuring instruction operands are ready to be\nlowered into machine code, but keeps temporaries as virtual registers. For\nexample, for x86-64, it models memory operands and will turn a sequence of IR\ninstructions like:</p>\n<pre><code>;; int *ptr, res;\ni64 %t2 = shl %index, 2\nptr %t3 = add %ptr, %t2\n;; res = ptr[index]\ni32 %res = loads32 %t3</code></pre>\n<p>into</p>\n<p><code>i32 %res = loads32 addr [%ptr + %index * 4]</code>\nIt does other things like introducing register constraints for certain operations, but anyway because it only expands into small sequences of instructions like this, it couldn&#39;t be the cause.</p>\n<p>Register allocation is the process of mapping virtual temporaries to the target architecture&#39;s physical registers. For the previous snippet, that might result in</p>\n<p><code>i32 RAX = loads32 addr [RSI + RDX * 4]</code>\nwhich is compiled to the x86 instruction <code>mov eax, DWORD PTR [rsi + rdx * 4]</code>.</p>\n<p>The problem is that there are infinite virtual temporaries but a small number of registers,\nso you have to choose which variables get which registers and when there are not enough\nregisters for all of them, some of them have to go on the stack, which is called <em>spilling</em>.</p>\n<p>Most operations require values to be in registers, so spilled variables often have to be moved back and forth between registers. Or even worse, they might have to be moved between two different stack slots in some situations.</p>\n<p>antcc has a basic implementation of <a href=\"https://c9x.me/compile/bib/Wimmer10a.pdf\" rel=\"nofollow ugc noopener\">linear scan regalloc</a>. If there is\na lot of register pressure and many temporaries get spilled, potentially a lot\nof instructions can be created just to shuffle values between stack and\nregisters.</p>\n<p>And indeed, comparing relative IR size increase before and after regalloc at -O2, the +11%\ndifference was turning into +70% blowup after regalloc... looking at the disassembly,\n<strong>&gt;40%</strong> of instructions in <code>luaV_execute</code> were just stack⇄register <code>mov</code>s. That&#39;s really bad,\nsomething being inlined must be resulting in this dreadful register pressure and spilling,\nbut it&#39;s not obvious what.</p>\n<p>So let&#39;s examine the pre-regalloc IR:</p>\n<pre><code>&lt;&lt; Before regalloc &gt;&gt;\nfunction luaV_execute : void(struct lua_State *, struct CallInfo *)\nabi: (RDI, RSI)\n   ....\n    ptr %4613 = add %0, 56\n    ptr %5340 = add %0, 40\n    b @2\n  @2:   ; preds: @1, @892       ; loop depth: 1\n    i64 %4489 = phi @1 undef, @892 %3124\n    i64 %3030 = phi @1 undef, @892 %3065\n    i64 %4636 = phi @1 undef, @892 %2287\n    i64 %4764 = phi @1 undef, @892 %3869\n    i64 %2874 = phi @1 undef, @892 %3929\n    i64 %2694 = phi @1 undef, @892 %3981\n    i64 %4872 = phi @1 undef, @892 %3804\n    i64 %2297 = phi @1 undef, @892 %3497\n    i64 %5062 = phi @1 undef, @892 %3158\n    i64 %2208 = phi @1 undef, @892 %3145\n    i64 %2161 = phi @1 undef, @892 %4191\n    i64 %5275 = phi @1 undef, @892 %4225\n    i64 %5290 = phi @1 undef, @892 %4252\n    i64 %1791 = phi @1 undef, @892 %4370\n    i64 %3813 = phi @1 undef, @892 %3195\n    i64 %3807 = phi @1 undef, @892 %4403\n  ....</code></pre>\n<p>Oh. That looks strange.</p>\n<p>My compiler like most modern backends uses a <a href=\"https://en.wikipedia.org/wiki/Static_single-assignment_form\" rel=\"nofollow ugc noopener\">Static Single-Assignment</a> representation.\nIn this form, straight-line code such as (I&#39;m using C-like pseudocode to simplify):</p>\n<pre><code>x = a + b\nx = x + y    # reassigning x\nuse(x)</code></pre>\n<p>Becomes:</p>\n<pre><code>x1 = a + b\nx2 = x1 + y  # fresh definition\nuse(x2)</code></pre>\n<p>That is, there are no variable mutation instructions. The reason this representation is great for compilers is that it simplifies all kinds of data-flow analysis and permits some more powerful optimizations than the non-SSA counterpart.</p>\n<p>With control flow, data flow merges are explicit in the IR:</p>\n<pre><code>if (cond) {\n    x = a + 1\n} else {\n    x = b - 1\n}\nuse(x)</code></pre>\n<p>In SSA form is represented as:</p>\n<pre><code>if (cond) B0: {\n    x1 = a + 1\n} else B1: {\n    x2 = b - 1\n}\nx3 = phi(B0: x1, B1: x2)\nuse(x3)</code></pre>\n<p>This magic &quot;phi&quot; instruction&lt;sup&gt;<a href=\"https://386s.org/w/lua-antcc-pessimization/#fn-4\" rel=\"nofollow ugc noopener\">[4]</a>&lt;/sup&gt; has as its result the value of <code>x1</code> or <code>x2</code> depending on which\ncontrol flow branch was taken. Same approach can be applied for variables modified in loops:</p>\n<pre><code>i = 0\nwhile (cond) {\n    use(i)\n    i += 1\n}</code></pre>\n<p>in SSA:</p>\n<pre><code>i0 = 0\nEntry: while (cond) {\n  LoopBegin:\n    i_l = phi(Entry: i0, LoopEnd: i_n)\n    use(i_l)\n    i_n = i_l + 1\n  LoopEnd:\n}</code></pre>\n<p>Textually, it looks like the <code>phi</code> function references <code>i_n</code> before its definition, but\nimportantly that value is only taken in the loop back-edge (<code>LoopEnd -&gt; LoopBegin</code>),\nand at that point the value of <code>i_n</code> is available.</p>\n<p>A key thing to realize here is that this <code>i_l</code> temporary, being a loop phi, is live\nacross the whole loop, since its value is used to compute the next iteration.</p>\n<p>Going back to that IR dump from Lua:</p>\n<pre><code>@2:   ; preds: @1, @892       ; loop depth: 1\n  i64 %4489 = phi @1 undef, @892 %3124\n  i64 %3030 = phi @1 undef, @892 %3065\n  ...</code></pre>\n<p>Ignore the undefs for now, and notice the <code>; preds</code> line indicating the\npredecessors of that block: <code>@1</code>, the beginning of the function, and <code>@892</code>,\nhundreds of blocks later: the end of the main interpreter loop. Meaning, we\nhave 16 phis that are live across the whole loop. This is what is creating a\nlot of register pressure, wrecking register allocation and resulting in that\nbig code size blowup from stack spilling traffic. So, what are these phis,\nwhere do they come from, and why the <code>undef</code>s...</p>\n<p>C programs don&#39;t start in SSA form, so they have to go through a transformation pass. I borrowed the terminology and design from clang/LLVM in my compiler:</p>\n<ol><li><p>Variables always start as virtual stack slots, defined with an <code>alloca</code> pseudo-instruction.&lt;sup&gt;<a href=\"https://386s.org/w/lua-antcc-pessimization/#fn-5\" rel=\"nofollow ugc noopener\">[5]</a>&lt;/sup&gt;  - Since local variables in C can have their address taken&lt;sup&gt;<a href=\"https://386s.org/w/lua-antcc-pessimization/#fn-6\" rel=\"nofollow ugc noopener\">[6]</a>&lt;/sup&gt; , this</p><p>directly models the C abstract machine&#39;s definition of<code>automatic variables</code> : a physical addressable stack slot that can be read from\nor written to.</p></li><li>Since local variables in C can have their address taken</li><li>Variable reads turn into memory <code>load</code> instructions, and writes turn into<code>store</code> instructions.</li><li><p>Later, the <code>mem2reg</code> pass collects stack<code>alloca</code> s whose address</p><p>does not escape (meaning they are only used for direct reads and writes\nthat the compiler can observe), and &quot;promotes&quot; them into SSA temporaries,\ninserting phi instructions where necessary.</p></li></ol>\n<p>So C code like:</p>\n<pre><code>int x;\nx = foo();\n...\nif (x &lt; 0) { x = -x; }\nreturn x;</code></pre>\n<p>Initially generates an IR that looks like:</p>\n<pre><code>@1:\n  ptr %x = alloca 4 bytes\n  i32 %t1 = call foo()\n  store i32 %x, %t1\n  ...\n  i32 %t2 = load i32 %x\n  bool %cond = lth %t2, 0\n  br %cond, true -&gt; @2, false -&gt; @3\n@2:\n  i32 %t3 = load i32 %x\n  i32 %t4 = neg %t3\n  store i32 %x, %t4\n  b @3\n@3:\n  i32 %t5 = load i32 %x\n  ret %t5</code></pre>\n<p>And mem2reg turns it to:</p>\n<pre><code>@1:\n  i32 %x1 = call foo()\n  ...\n  bool %cond = lth %x1, 0\n  br %cond, true -&gt; @2, false -&gt; @3\n@2:\n  i32 %x2 = neg %x1\n  b @3\n@3:\n  i32 %x3 = phi @1 %x1, @2 %x2\n  ret %x3</code></pre>\n<p>I&#39;m glossing over the trickiest part of this, which is knowing when and where\nto insert these phi instructions in the general case. There&#39;s basically two\nalgorithms to do it, and the one I&#39;m using is from the paper <a href=\"https://pp.ipd.kit.edu/uploads/publikationen/braun13cc.pdf\" rel=\"nofollow ugc noopener\">&quot;Simple and\nEfficient Construction of SSA Form (Braun et al, 2013)&quot;</a>.  The gist of\nit is: walk the control-flow graph (CFG) in reverse post-order (RPO); when you\nsee a write, memoize the current definition keyed by the block; when you see a read,\nlook for that definition or query the predecessors&#39; definitions recursively and\ninsert phis when that lookup finds distinct definitions. Loops are trickier:\nbecause the back-edge predecessors haven&#39;t been visited yet, you have to insert\na provisional phi and resolve it later.</p>\n<p>Something about C that almost every language that came after it removed as a Bad Idea is the ability to leave variables uninitialized. For example:</p>\n<pre><code>int x;\nif (foo()) {\n    int y = bar();\n    x = y;\n}\nuse(x);</code></pre>\n<p>After the control flow merge, <code>x</code> is <code>phi(uninitialized, y)</code>. That is what\nthe <code>undef</code> in my IR is. The way it&#39;s introduced in my mem2reg is that if the\nrecursive &quot;read current definition&quot; function reaches all the way to the\nbeginning of the function without finding a definition, it gives it <code>undef</code>.\n(Later on, moves and operations on <code>undef</code> can be effectively optimized away.)</p>\n<p>So was the Lua interpreter reading uninitialized variables? Well, no. Look at some of the affected code from the interpreter function:</p>\n<pre><code>  vmcase(OP_SHRI) {\n    TValue *rb = vRB(i);\n    int ic = GETARG_sC(i);\n    lua_Integer ib;\n    if (tointegerns(rb, &amp;ib)) {\n      pc++; setivalue(s2v(ra), luaV_shiftl(ib, -ic));\n    }\n    vmbreak;\n  }\n  // several other instructions follow that same template</code></pre>\n<p>The key is the <code>tointegerns</code> macro, which in turn calls the function <code>luaV_tointegerns</code>:</p>\n<pre><code>int luaV_tointegerns (const TValue *obj, lua_Integer *p, F2Imod mode) {\n  if (ttisfloat(obj))\n    return luaV_flttointeger(fltvalue(obj), p, mode); // &lt;- this gets inlined\n  else if (ttisinteger(obj)) {\n    *p = ivalue(obj);\n    return 1;\n  }\n  else\n    return 0;\n}</code></pre>\n<p>In -O2, this function and <code>luaV_flttointeger</code> get inlined, resulting in an\nexpansion like this (simplified):</p>\n<pre><code>  vmcase(OP_SHRI) {\n    ...\n    lua_Integer ib;   // &lt;--- declaration\n    int tointegerns_ret;\n    if (ttisinteger(rb)) {\n      ib = ivalue(rb);   // &lt;--- write\n      tointegerns_ret = 1;\n    } else if (ttisfloat(rb)) {\n      ...\n      ib = (lua_Integer)fvalue(f);    // &lt;--- write\n      tointegerns_ret = 1;\n    } else {\n      tointegerns_ret = 0;\n    }\n    if (tointegerns_ret) {\n      ... (use ib)\n    }\n    vmbreak;\n  }</code></pre>\n<p><code>mem2reg</code> is always ran after every inlining pass. The <code>undef</code> in question\ncomes from the variable <code>ib</code> which I&#39;ve marked. You can see that the local\nvariable is initialized in every path where the inlined function returns <code>1</code>,\nbut <code>mem2reg</code> cannot correlate that with the later <code>if (tointegerns_ret)</code> guard,\nso it sees a possibly uninitialized read.</p>\n<p>But that does <em>not</em> tell the whole story! It explains the <code>undef</code>, but not why\nthe phi is live across the whole loop. After all, this local is scoped to a\nblock inside a branch of the loop, it does not live across loop iterations...</p>\n<p>Eventually I figured out the root cause: I was treating the idea of &quot;variables\nstart as uninitialized&quot; <em>too</em> literally in mem2reg. When it encountered an\n<code>alloca</code>, it didn&#39;t give it a definition for the current block. This sounds\ncorrect, and the recursive lookup in the algorithm terminates at the function\nentry with an <code>undef</code>. But recall the catch with loops being that when you\nencounter a backedge, you don&#39;t know if there&#39;s a later definition, because you\nhaven&#39;t visited the whole loop yet, so you insert a provisional phi...</p>\n<p>In essence, my mem2reg was behaving as if code like:</p>\n<pre><code>int foo() {\n  while (...) {\n    int x;\n    if (...) {\n      x = bar();\n    }\n    use(x);\n  }\n}</code></pre>\n<p>was actually</p>\n<pre><code>int foo() {\n  int x;\n  while (...) {\n    if (...) {\n      x = bar();\n    }\n    use(x);\n  }\n}</code></pre>\n<p>In the 2nd function, the value of <code>x</code> is kept across iterations, whereas in the\n1st, the value of <code>x</code> is a &#39;fresh&#39; uninitialized every time the <code>if</code> branch is\nnot taken...  This is not a miscompilation: as far as C is concerned, this is\nfine because uninitialized variables&#39; values are not legally usable anyway. And\nif you frame it in terms of stack-allocated variables, it&#39;s a sensible\napproach: the slot for x is fixed for the whole function and doesn&#39;t get\nreset between loop iterations, so it keeps the previous iteration&#39;s whatever\nit was and it doesn&#39;t matter. But the problem is that in the context of SSA\nform, this generates something like:</p>\n<pre><code>while (...) {\n  %x = phi(undef, %x2)\n  if (...) {\n    %x1 = bar();\n  }\n  %x2 = phi(%x, %x1)\n  use(%x2);\n}</code></pre>\n<p>And it is these loop phis that result in the pathological register allocation. The fix was one line:</p>\n<pre><code>--- a/src/ir_mem2reg.c\n+++ b/src/ir_mem2reg.c\n@@ -244,6 +244,8 @@ mem2reg(Function *fn) {\n             v-&gt;cls = k;\n             v-&gt;i = var;\n             sb.vars[var] = v;\n+            v-&gt;curdefs[blk-&gt;id] = UNDREF;\n          } else {\n             sb.vars[var] = DEAD;\n          }</code></pre>\n<p>Initializing the var as undefined (a nice oxymoron) at the place of the\n<code>alloca</code> ensures that the recursive lookup doesn&#39;t cross loop backedges and\ninsert phis in the loop header. This is enough to fix this case, and that\nchange made the mysterious pessimization disappear.</p>\n<p>This problem can still bite for C code that is old-style declaring all\nvariables at the top of the function. It&#39;s possible to fix this general case by\nusing <a href=\"https://en.wikipedia.org/wiki/Tarjan&#39;s_strongly_connected_components_algorithm\" rel=\"nofollow ugc noopener\">Tarjan&#39;s algorithm</a> to find the graph SCCs of the phi data-flow to\ndetect cycles, and making use of these undef semantics when classifying\nphis as redundant; but I haven&#39;t implemented that yet.</p>\n<p>Jump threading an inlined function used directly as a branch condition, or more\ngenerally SSA-aware phi jump threading, could&#39;ve also fixed the branch\ncorrelation issue that led to <code>mem2reg</code> not seeing the dominating write of\nthose variables and introducing undefs.</p>\n<p>I made use of AI agents to help debug this issue. It saved me a lot of time in investigating, filtering through disassemblies and IR dumps, running the compiler with different inlining thresholds and measuring, etc; but it was not good at trying to find the root cause. It proposed probably around 10 convoluted &quot;fixes&quot; before I eventually arrived at the conclusion myself. Still &lt;$1 well spent for the sake of reducing tedium.</p>\n<p>Lua 5.4.7, <a href=\"https://386s.org/asset/202610-antcc-bench.lua\" rel=\"nofollow ugc noopener\">bench.lua</a>, AMD Ryzen 5 5600G. Take\nthese with a grain of salt.</p>\n<p>Pre-fix:</p>\n<p>antcc was worse than even tcc, but that&#39;s mainly because my frontend was lowering switch statements to linear if-else chains. I implemented a simple binary search codegen, which was one of those things that I just hadn&#39;t gotten around to yet; and a few other minor optimizations; after this fix.</p>\n<p>Current numbers:</p>\n<p>Do note that antcc performs <code>mem2reg</code> by default when passed no optimization\nflags, unlike gcc. It might seem counterintuitive, but from my testing, running\n<code>mem2reg</code> improves compile times too; presumably due to the reduced number of\nstack store/load instructions for the backend to churn through.</p>\n<p>And compile times:</p>\n<p>I omitted the bars for clang because they are all very similar to gcc.</p>\n<ol><li></li></ol>\n<p>The only difference between -O1 and -O2 is in inlining thresholds and -O2 running some optimization passes more than once. <a href=\"https://386s.org/w/lua-antcc-pessimization/#fr-1-1\" rel=\"nofollow ugc noopener\">↩</a></p>\n<ol start=\"2\"><li></li></ol>\n<p>This is why LuaJIT&#39;s interpreter in hand-written assembly beats almost every other bytecode interpreter, <a href=\"http://lua-users.org/lists/lua-l/2011-02/msg00742.html\" rel=\"nofollow ugc noopener\">and it&#39;s not just about the while-switch loop</a><a href=\"https://386s.org/w/lua-antcc-pessimization/#fr-2-1\" rel=\"nofollow ugc noopener\">↩</a></p>\n<ol start=\"3\"><li></li></ol>\n<p>Intermediate representation <a href=\"https://386s.org/w/lua-antcc-pessimization/#fr-3-1\" rel=\"nofollow ugc noopener\">↩</a></p>\n<ol start=\"4\"><li></li></ol>\n<p>variously known as phi function, φ, /faɪ/, /fi/, phony instruction <a href=\"https://386s.org/w/lua-antcc-pessimization/#fr-4-1\" rel=\"nofollow ugc noopener\">↩</a></p>\n<ol start=\"5\"><li></li></ol>\n<p>Not to be confused with the dynamic <code>alloca()</code> function.<a href=\"https://386s.org/w/lua-antcc-pessimization/#fr-5-1\" rel=\"nofollow ugc noopener\">↩</a></p>\n<ol start=\"6\"><li></li></ol>\n<p>The <code>register</code> storage class exists, but it doesn&#39;t really matter in\nmodern architectures so you (almost) never see it used.<a href=\"https://386s.org/w/lua-antcc-pessimization/#fr-6-1\" rel=\"nofollow ugc noopener\">↩</a></p>","headings":[]}}