{"article":{"slug":"rats-register-allocator","title":"rat's register allocator","subtitle":null,"summary":"The author of the rat compiler backend explains replacing a 1,392-line linear scan register allocator with a 584-line priority bin-packing allocator that produces better x86-64 code. The post walks through live ranges, fixed registers, coalescing, register picking and spilling, then measures what each feature was worth, finding that cheap techniques like coalescing and hints matter most.","content_type":"blog_post","language":"en","canonical_url":"https://hexrat.cc/pages/blog/2026_10_07","author":{"name":null,"url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"hexrat.cc","url":"https://hexrat.cc/","listing_slug":null,"listing":null},"topics":[{"name":"Systems Programming","slug":"systems-programming","url":"https://listedarticles.com/topics/systems-programming"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2427,"reading_minutes":11,"published_at":"2026-10-07T00:00:00.000Z","added_at":"2026-10-08T02:11:31.918Z","updated_at":"2026-10-08T02:11:31.918Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/rats-register-allocator","markdown_url":"https://listedarticles.com/articles/rats-register-allocator.md","example":false,"citation":"hexrat.cc. \"rat's register allocator.\" 7 Oct 2026. https://hexrat.cc/pages/blog/2026_10_07 (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://hexrat.cc/pages/blog/2026_10_07"},"body_markdown":"# rat's register allocator\n\nOctober 7th, 2026\n\n[rat](https://github.com/hexratcc/rat) is my smallish compiler backend (with a semi-working\nC99 frontend). Its x86-64 code generator\ntranslates the [intermediate representation](https://en.wikipedia.org/wiki/Intermediate_representation) (IR) into x86-64 instructions. These use an unlimited number of virtual\nregisters (vregs). The register allocator maps each vreg to a physical register: a general-purpose register (12 can\nbe used) or an xmm register (14 on Linux[1](#fn1)). When no register is free,\nit maps the vreg to a stack slot.\n\nFor a long time rat used a [linear scan](https://dl.acm.org/doi/10.1145/330249.330250) allocator, which visits live ranges in program order. It worked, but it grew one fix at a time, to\n1392 lines. So I measured which of its parts helped, threw the rest away and wrote a priority bin-packing\nallocator in 584 lines. It visits live ranges by importance and puts each one in the first register where it\nfits. It is the same family as LLVM's\n[greedy allocator](https://blog.llvm.org/2011/09/greedy-register-allocation-in-llvm-30.html), minus most of the hard parts, and it makes\n[better code](#numbers).\n\nA value is **live** from where it is written to where it is last read. Two values can share a register\nonly if they are never live at the same time.\n\nWhen too many values are live at one point, some go to memory: they are **spilled**. A spill\ncosts a store and a load. The best assignment is NP-hard to find[2](#fn2),\nso all practical allocators use heuristics.\n\nThe [calling convention](https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI) adds two rules. A call can overwrite the **caller-saved** registers\n(`rax rcx rdx rsi rdi r8-r11` and all xmm registers on Linux). A function must restore the\n**callee-saved** registers (`rbx rbp r12-r15`) before it returns. As an example, this\nfunction keeps `y` live across a call:\n\n```\nlong g(long);\nlong h(long x, long y) {\n\tlong t = g(x);\n\treturn t + y;\n}\n```\n\nBefore allocation, `rdi`, `rsi` and `rax` are fixed by the calling\nconvention, and `v1`-`v4` are vregs:\n\n```\n0  v1 = copy rdi      ; x\n1  v2 = copy rsi      ; y\n2  rdi = copy v1      ; argument of g\n3  call g             ; clobbers caller-saved\n4  v3 = copy rax      ; t\n5  v4 = copy v3\n6  v4 = add v4, v2\n7  rax = copy v4\n8  ret\n```\n\nx86 `add` writes over its first operand (two-address), so instruction 5 copies `t`\nfirst. After allocation, at -O1:\n\n```\npush rbp\nmov  rbp, rsp\nsub  rsp, 0x8\npush rbx              ; rbx is callee-saved: save it\nmov  rbx, rsi         ; y\ncall g                ; x is already in rdi\nadd  rax, rbx         ; t stays in rax\npop  rbx\nleave\nret\n```\n\nFive of the six copies are gone, and `y` went to a callee-saved register. No code in the\nallocator says \"put values that cross a call in callee-saved registers\". It falls out of the design, and\nthat is my favourite part.\n\n### Five steps\n\nThe allocator runs five steps per function:\n\n1. [Live ranges](#live-ranges): number the instructions and find where each vreg is live.\n2. [Fixed registers](#fixed-registers): mark where the code uses physical registers\n   directly.\n3. [Coalescing](#coalescing): join vregs that a copy connects into one group (a\n   **bundle**[3](#fn3)), so the copy can go away.\n4. [Picking registers](#picking-registers): give each bundle a register, most important\n   first.\n5. [Spilling](#spilling): give stack slots to bundles with no register, then\n   rewrite the code.\n\nEach bundle keeps its register or stack slot for its full lifetime. The allocator never:\n\n* takes a register back from a bundle (no eviction)\n* [splits](https://en.wikipedia.org/wiki/Register_allocation#Split_allocation) a range between a register and memory\n* runs a step two times\n\nThese parts make real allocators big. My [measurements](#numbers) say rat does\nnot miss them much.\n\n### Live ranges\n\n#### Slots\n\nInstruction `i` gets two slots: it reads its operands at `2i` and writes its results\nat `2i+1`. A live range is a sorted list of `[start, end]` slot segments.\n\nWhere a source ends depends on the instruction:\n\n* **Copies:** the source ends at the read slot, and the destination starts at the write\n  slot. In instruction 2, `rdi = copy v1`, `v1` ends at slot\n  4 and `rdi` starts at slot 5. They do not\n  overlap, so they can share a register and the copy becomes a no-op.\n* **Other instructions:** a source stays live through the write slot, so a result never overwrites a\n  different operand. `v2` is written by instruction 1 and last read by the\n  `add` at instruction 6, so it lives in `[3, 13]`.\n\n#### Live-out sets\n\nrat finds the vregs that are **live-out** of each block: a later block can still read them. Many\ncompilers do this with one bitset per block and a\n[fixed-point loop](https://en.wikipedia.org/wiki/Live-variable_analysis). rat does\none vreg at a time instead:\n\n1. The vreg is live into each block that reads it before it writes it.\n2. From each such block, a worklist goes back through the predecessors and marks the vreg live-out in\n   each.\n3. The walk stops at a block that defines the vreg.\n\nThe cost grows with the blocks where each vreg is live, not with\n`blocks * vregs`.[4](#fn4)\n\n#### Segments and weights\n\nThen rat walks each block backward from its live-out set and makes the segments. The same walk sums a\n**weight** per vreg: the cost of its spill.\n\nEach def and each use adds `3d`, where `d` is the loop depth (up to 11):\n\n| def or use in | adds |\n| --- | --- |\n| straight-line code | 1 |\n| a loop | 3 |\n| a doubly nested loop | 9 |\n\n#### Holes\n\nA live range can have **holes**, gaps where the vreg is dead. Blocks are numbered in code order, so a range\nthat skips a block has a hole there:\n\n```\nlong f(long* a, long n) {\n\tfor(long i = 0; i < n; ++i)\n\t\tif(a[i] < 0)\n\t\t\ta[i] = 0;\n\treturn n * 3;\n}\n```\n\nThe exit block sits between the loop blocks:\n\n```\nmov  eax, 0x0         ; offset 8*i, rax in the loop\ncmp  rdx, rdi\njl   loop\nexit:\nlea  rax, [rdi+rdi*2] ; n*3 in the hole of rax\nret\nloop:\nmov  rcx, r8\nadd  rcx, rax\n...\nadd  rax, 0x8\ncmp  rdx, rdi\njl   loop\njmp  exit\n```\n\nThe offset in `rax` is dead in the exit block, so `n*3` (one\n[`lea`](https://www.felixcloutier.com/x86/lea)) can use `rax`,\nwhich is also the return register. A free win from block order.\n\n### Fixed registers\n\nrat numbers its registers 1 to 40, so one `U64` holds a set of them. Each slot gets one mask,\n`busy[slot]`. A set bit means that register is busy at that slot.\n\nThe same backward walk marks the physical registers the code uses directly:\n\n| use | register | busy |\n| --- | --- | --- |\n| incoming argument | argument register | until the copy that reads it |\n| call argument | argument register | from the copy that sets it to the call |\n| call | all caller-saved | in the two slots of the call |\n| return value | `rax` | from the call to the copy that reads it |\n| [division](https://www.felixcloutier.com/x86/idiv) | `rax rcx rdx` | reads `rax rcx`, writes `rax rdx` |\n\nThe masks and ranges of `h`:\n\n```\ninstr   0  1  2  3  4  5  6  7  8\nslot    rw rw rw rw rw rw rw rw rw\nrdi     #. .. .#### .. .. .. .. ..\nrsi     ####. .. ## .. .. .. .. ..\nrax     .. .. .. ####. .. .. .####\nothers  .. .. .. ## .. .. .. .. ..\nv1 x    .======. .. .. .. .. .. ..\nv2 y    .. .================ .. ..\nv3+v4 t .. .. .. .. .=========. ..\n```\n\n`r` and `w` are the read and write slots. `#` is busy, `=` is a\nlive range and `.` is free. A bar continues across the gap between instructions. \"others\" is\nevery other caller-saved register.\n\nWhen a bundle gets a register, rat sets that register's bit in every slot of its live range. After that,\nvregs and fixed registers are bits in the same masks. Each group of 64 slots also has a summary mask, the\nOR of its 64 masks, so a long range can skip 64 slots at a time.\n\n### Coalescing\n\nA copy between two vregs of the same class is a candidate for\n[coalescing](https://en.wikipedia.org/wiki/Register_allocation#Coalescing). These\ncopies come from:\n\n* two-address instructions\n* [phi nodes](https://en.wikipedia.org/wiki/Static_single-assignment_form): a\n  value that comes from different blocks at a join point\n\nIf the two live ranges do not overlap, the vregs become one bundle. It has the merged segments and the summed\nweight. rat deletes a copy inside one bundle. In\n`h`, `v3` is `[9, 10]` and `v4` is `[11, 14]`, so\nthey merge.\n\n* rat sorts the copies by loop depth, deepest first. Hot copies merge before cold copies can block them.\n* The bundles are kept in a [union-find](https://en.wikipedia.org/wiki/Disjoint-set_data_structure).\n* A merge first walks both segment lists to check for overlap. rat skips a merge when the two bundles\n  together have more than 256 segments.\n\nA copy between a vreg and a physical register sets a **hint** instead: the bundle prefers that register\nif it is free.\n\n### Picking registers\n\nEach bundle gets a priority:\n\n```\npriority = weight / sqrt(length in slots)\n```\n\n* Short, hot ranges come first: they matter most and are the easiest to place.\n* Long, cold ranges come last and get spilled.\n* `sqrt` keeps a long loop counter from losing too much priority.\n\nrat calls `pick` on each bundle in priority order:\n\n```\n// cls: register class, gp or xmm\nPhysReg pick(VReg v) {\n\tU64 blocked = ~allocatable[cls];\n\tfor(auto [start, end] : segs[v])\n\t\tfor(I32 s = start; s <= end; ++s)\n\t\t\tblocked |= busy[s]; // or 64 at a time\n\tif(hint[v] != kNoReg && !(blocked >> hint[v] & 1))\n\t\treturn hint[v];\n\t// caller-saved first, callee-saved last\n\treturn firstFree(order[cls], blocked);\n}\n```\n\n#### Picking in `h`\n\n| bundle | hint | gets |\n| --- | --- | --- |\n| `v1` | `rdi` | `rdi` |\n| `v3+v4` | `rax` | `rax` |\n| `v2` | `rsi` | `rbx` |\n\nIn the diagram, `rdi` is busy only before and after `v1`, so `v1` gets it.\nBoth copies become `mov rdi, rdi`, and the\n[peephole pass](https://en.wikipedia.org/wiki/Peephole_optimization) deletes them after\nallocation.\n\n`v2` crosses the call. Every caller-saved register is busy in the call slots, so the first free\nregister is `rbx`, the first callee-saved one. The prologue saves\nonly the callee-saved registers rat used.\n\nOn Linux, no xmm register is callee-saved, so a float that crosses a call always goes to\nthe stack.\n\n### Spilling\n\nA bundle with no free register is spilled for its full lifetime. Then:\n\n1. rat sorts the spilled bundles by start. It reuses a stack slot when the last bundle in it has ended.\n2. rat rewrites the code. A vreg whose bundle got a register becomes that register.\n3. Before each instruction, rat loads each spilled operand into a temporary register. After it, rat stores\n   each spilled result.\n\nThe temporary is `r10` or `r11` (`xmm14` or `xmm15` for floats).\nNo bundle ever gets these. If both are busy, rat takes the first register free at that\ninstruction.\n\nTwo cases need no temporary. A copy between a register and a spilled bundle becomes the load or the store\nitself. A call reads a spilled stack argument from its stack slot directly.\n\nIn `p`, 14 values are live at once:\n\n```\nvoid p(long* a) {\n\tlong x0 = a[0], x1 = a[1], ..., x13 = a[13];\n\ta[0] = x0 * x13; a[1] = x1 * x12; a[2] = x2 * x11;\n\ta[3] = x3 * x10; a[4] = x4 * x9;  a[5] = x5 * x8;\n\ta[6] = x6 * x7;\n}\n```\n\n16 registers minus `rsp`, `rbp`, `r10`, `r11` and\n`rdi` (which holds `a`) leaves 11 for 14 values. `x0`-`x6` also\nhold the products (two-address\n[`imul`](https://www.felixcloutier.com/x86/imul)), so they have more\nuses. Of `x7`-`x13`, the three with the longest ranges go to the stack.\n\nBefore the peephole pass:\n\n```\nmov  r12, [rdi+0x30]  ; x6, in a register\nmov  r10, [rdi+0x38]  ; x7, spilled\nmov  [rbp-0x8], r10\nmov  r10, [rdi+0x40]  ; x8, spilled\nmov  [rbp-0x10], r10\nmov  r10, [rdi+0x48]  ; x9, spilled\nmov  [rbp-0x18], r10\n...\nmov  r10, [rbp-0x18]  ; reload x9\nimul r9, r10\nmov  r10, [rbp-0x10]  ; reload x8\nimul rbx, r10\nmov  r10, [rbp-0x8]   ; reload x7\nimul r12, r10\n```\n\nNo instruction between the store of `x9` and its reload writes `r10`. So the peephole\npass deletes the reload. Then nothing reads that stack slot, so it also deletes the store.\n\n### It can be dumb\n\nWithout eviction, an early decision is final. Here is the case that annoys me most:\n\n```\nlong sum(long* a, long n) {\n\tlong s = 0;\n\tfor(long i = 0; i < n; ++i)\n\t\ts += a[i];\n\treturn s;\n}\n```\n\nrat compiles it to:\n\n```\nmov  r9, rdi          ; a: rdi was taken by a[i]\nmov  r8, rsi          ; n: rsi was taken by s\n...\nexit:\nmov  rax, rsi         ; s: rax was taken by a+8*i\nret\nloop:\nmov  rax, r9\nadd  rax, rcx         ; rax = a + 8*i\nmov  rdi, [rax]       ; rdi = a[i]\nadd  rsi, rdi\n...\n```\n\nThe loop values are short and hot, so they go first:\n\n1. The address `a+8*i` takes `rax`.\n2. `s` loses its hint `rax` and takes `rsi`.\n3. `a[i]` takes `rdi`.\n4. `a` and `n` come last and lose their hints too.\n\nThe result is three movs, all outside the loop.[5](#fn5) An allocator with\neviction would fix this chain. I decided three cold movs are not worth the extra code.\n\n### Numbers\n\nAgainst the old allocator:\n\n| metric | change |\n| --- | --- |\n| allocator source | -58% |\n| instructions emitted | -3.6% |\n| stores emitted | -22% |\n| allocator time, [sqlite](https://www.sqlite.org/amalgamation.html) at -O0 | -77% |\n| total compile time, sqlite at -O0 | -43% |\n\n#### What each feature was worth\n\nBefore the rewrite, I turned off each old feature in turn and measured the\ncode. This was the most useful hour of the project:\n\n| feature | instructions saved | new allocator |\n| --- | --- | --- |\n| copy coalescing and copy hints | about a third | kept |\n| live range holes | 10% | kept |\n| spill choice by use weight | 5.5% | kept |\n| hints to physical registers | 1% | kept |\n| optimistic second try at spilled ranges (37% of allocator time) | 0.01% | dropped |\n| [rematerialization](https://en.wikipedia.org/wiki/Rematerialization) (recompute instead of reload) | not measurable | dropped |\n| spill slot cache | not measurable | dropped |\n\n### Wrapping up\n\nNo eviction, no splitting, no second pass, and the new allocator still beats the old one. Most of the\nquality comes from cheap things: coalescing, hints, holes and use weights.\n\nThe lesson for me: measure the old code before I port it. Much of the old allocator did nothing.\n\n### References\n\n* Poletto and Sarkar, [Linear scan   register allocation](https://dl.acm.org/doi/10.1145/330249.330250): the base of the old allocator.\n* Max Bernstein, [Linear scan register   allocation on SSA](https://bernsteinbear.com/blog/linear-scan/) and [Linear scan with lifetime holes](https://bernsteinbear.com/blog/linear-scan-lifetime-holes/): a readable pair of posts.\n* Jakob Stoklund Olesen,\n  [Greedy   register allocation in LLVM 3.0](https://blog.llvm.org/2011/09/greedy-register-allocation-in-llvm-30.html): the big version of this idea, with eviction and splitting.\n* Chris Fallin, [Cranelift,   part 4: a new register allocator](https://cfallin.org/blog/2022/06/09/cranelift-regalloc2/): a long, good read on bundles.\n* Matt Keeter, [The solid-state   register allocator](https://www.mattkeeter.com/blog/2022-10-04-ssra/): even smaller, it runs in one backward pass.\n\n### Notes\n\n1. On Windows, only `xmm0`-`xmm3` can be used. `xmm4` and\n   `xmm5` are the spill temporaries, and rat does not use the callee-saved\n   `xmm6`-`xmm15`. [[back]](#ref1)\n2. [Chaitin et al.](https://en.wikipedia.org/wiki/Chaitin%27s_algorithm)\n   showed that any graph can be the interference graph of some program. So register allocation is at least as\n   hard as graph coloring. [[back]](#ref2)\n3. Cranelift's [regalloc2](https://cfallin.org/blog/2022/06/09/cranelift-regalloc2/) uses the same word for the same idea. [[back]](#ref3)\n4. Each block stores the last vreg that marked it, so the walk never clears a visited\n   array. [[back]](#ref4)\n5. The mov inside the loop, `mov rax, r9`, has a different\n   cause. It is the two-address copy for the add, and it always stays. [[back]](#ref5)\n","body_html":"<h1 id=\"rat-s-register-allocator\">rat&#39;s register allocator</h1>\n<p>October 7th, 2026</p>\n<p><a href=\"https://github.com/hexratcc/rat\" rel=\"nofollow ugc noopener\">rat</a> is my smallish compiler backend (with a semi-working\nC99 frontend). Its x86-64 code generator\ntranslates the <a href=\"https://en.wikipedia.org/wiki/Intermediate_representation\" rel=\"nofollow ugc noopener\">intermediate representation</a> (IR) into x86-64 instructions. These use an unlimited number of virtual\nregisters (vregs). The register allocator maps each vreg to a physical register: a general-purpose register (12 can\nbe used) or an xmm register (14 on Linux<a href=\"#fn1\">1</a>). When no register is free,\nit maps the vreg to a stack slot.</p>\n<p>For a long time rat used a <a href=\"https://dl.acm.org/doi/10.1145/330249.330250\" rel=\"nofollow ugc noopener\">linear scan</a> allocator, which visits live ranges in program order. It worked, but it grew one fix at a time, to\n1392 lines. So I measured which of its parts helped, threw the rest away and wrote a priority bin-packing\nallocator in 584 lines. It visits live ranges by importance and puts each one in the first register where it\nfits. It is the same family as LLVM&#39;s\n<a href=\"https://blog.llvm.org/2011/09/greedy-register-allocation-in-llvm-30.html\" rel=\"nofollow ugc noopener\">greedy allocator</a>, minus most of the hard parts, and it makes\n<a href=\"#numbers\">better code</a>.</p>\n<p>A value is <strong>live</strong> from where it is written to where it is last read. Two values can share a register\nonly if they are never live at the same time.</p>\n<p>When too many values are live at one point, some go to memory: they are <strong>spilled</strong>. A spill\ncosts a store and a load. The best assignment is NP-hard to find<a href=\"#fn2\">2</a>,\nso all practical allocators use heuristics.</p>\n<p>The <a href=\"https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI\" rel=\"nofollow ugc noopener\">calling convention</a> adds two rules. A call can overwrite the <strong>caller-saved</strong> registers\n(<code>rax rcx rdx rsi rdi r8-r11</code> and all xmm registers on Linux). A function must restore the\n<strong>callee-saved</strong> registers (<code>rbx rbp r12-r15</code>) before it returns. As an example, this\nfunction keeps <code>y</code> live across a call:</p>\n<pre><code>long g(long);\nlong h(long x, long y) {\n    long t = g(x);\n    return t + y;\n}</code></pre>\n<p>Before allocation, <code>rdi</code>, <code>rsi</code> and <code>rax</code> are fixed by the calling\nconvention, and <code>v1</code>-<code>v4</code> are vregs:</p>\n<pre><code>0  v1 = copy rdi      ; x\n1  v2 = copy rsi      ; y\n2  rdi = copy v1      ; argument of g\n3  call g             ; clobbers caller-saved\n4  v3 = copy rax      ; t\n5  v4 = copy v3\n6  v4 = add v4, v2\n7  rax = copy v4\n8  ret</code></pre>\n<p>x86 <code>add</code> writes over its first operand (two-address), so instruction 5 copies <code>t</code>\nfirst. After allocation, at -O1:</p>\n<pre><code>push rbp\nmov  rbp, rsp\nsub  rsp, 0x8\npush rbx              ; rbx is callee-saved: save it\nmov  rbx, rsi         ; y\ncall g                ; x is already in rdi\nadd  rax, rbx         ; t stays in rax\npop  rbx\nleave\nret</code></pre>\n<p>Five of the six copies are gone, and <code>y</code> went to a callee-saved register. No code in the\nallocator says &quot;put values that cross a call in callee-saved registers&quot;. It falls out of the design, and\nthat is my favourite part.</p>\n<h3 id=\"five-steps\">Five steps</h3>\n<p>The allocator runs five steps per function:</p>\n<ol><li><a href=\"#live-ranges\">Live ranges</a>: number the instructions and find where each vreg is live.</li><li><p><a href=\"#fixed-registers\">Fixed registers</a>: mark where the code uses physical registers</p><p> directly.</p></li><li><p><a href=\"#coalescing\">Coalescing</a>: join vregs that a copy connects into one group (a</p><p> <strong>bundle</strong><a href=\"#fn3\">3</a>), so the copy can go away.</p></li><li><p><a href=\"#picking-registers\">Picking registers</a>: give each bundle a register, most important</p><p> first.</p></li><li><p><a href=\"#spilling\">Spilling</a>: give stack slots to bundles with no register, then</p><p> rewrite the code.</p></li></ol>\n<p>Each bundle keeps its register or stack slot for its full lifetime. The allocator never:</p>\n<ul><li>takes a register back from a bundle (no eviction)</li><li><a href=\"https://en.wikipedia.org/wiki/Register_allocation#Split_allocation\" rel=\"nofollow ugc noopener\">splits</a> a range between a register and memory</li><li>runs a step two times</li></ul>\n<p>These parts make real allocators big. My <a href=\"#numbers\">measurements</a> say rat does\nnot miss them much.</p>\n<h3 id=\"live-ranges\">Live ranges</h3>\n<h4 id=\"slots\">Slots</h4>\n<p>Instruction <code>i</code> gets two slots: it reads its operands at <code>2i</code> and writes its results\nat <code>2i+1</code>. A live range is a sorted list of <code>[start, end]</code> slot segments.</p>\n<p>Where a source ends depends on the instruction:</p>\n<ul><li><p><strong>Copies:</strong> the source ends at the read slot, and the destination starts at the write</p><p>slot. In instruction 2, <code>rdi = copy v1</code>, <code>v1</code> ends at slot\n4 and <code>rdi</code> starts at slot 5. They do not\noverlap, so they can share a register and the copy becomes a no-op.</p></li><li><p><strong>Other instructions:</strong> a source stays live through the write slot, so a result never overwrites a</p><p>different operand. <code>v2</code> is written by instruction 1 and last read by the\n<code>add</code> at instruction 6, so it lives in <code>[3, 13]</code>.</p></li></ul>\n<h4 id=\"live-out-sets\">Live-out sets</h4>\n<p>rat finds the vregs that are <strong>live-out</strong> of each block: a later block can still read them. Many\ncompilers do this with one bitset per block and a\n<a href=\"https://en.wikipedia.org/wiki/Live-variable_analysis\" rel=\"nofollow ugc noopener\">fixed-point loop</a>. rat does\none vreg at a time instead:</p>\n<ol><li>The vreg is live into each block that reads it before it writes it.</li><li><p>From each such block, a worklist goes back through the predecessors and marks the vreg live-out in</p><p> each.</p></li><li>The walk stops at a block that defines the vreg.</li></ol>\n<p>The cost grows with the blocks where each vreg is live, not with\n<code>blocks * vregs</code>.<a href=\"#fn4\">4</a></p>\n<h4 id=\"segments-and-weights\">Segments and weights</h4>\n<p>Then rat walks each block backward from its live-out set and makes the segments. The same walk sums a\n<strong>weight</strong> per vreg: the cost of its spill.</p>\n<p>Each def and each use adds <code>3d</code>, where <code>d</code> is the loop depth (up to 11):</p>\n<div class=\"table-wrap\"><table><thead><tr><th>def or use in</th><th>adds</th></tr></thead><tbody><tr><td>straight-line code</td><td>1</td></tr><tr><td>a loop</td><td>3</td></tr><tr><td>a doubly nested loop</td><td>9</td></tr></tbody></table></div>\n<h4 id=\"holes\">Holes</h4>\n<p>A live range can have <strong>holes</strong>, gaps where the vreg is dead. Blocks are numbered in code order, so a range\nthat skips a block has a hole there:</p>\n<pre><code>long f(long* a, long n) {\n    for(long i = 0; i &lt; n; ++i)\n        if(a[i] &lt; 0)\n            a[i] = 0;\n    return n * 3;\n}</code></pre>\n<p>The exit block sits between the loop blocks:</p>\n<pre><code>mov  eax, 0x0         ; offset 8*i, rax in the loop\ncmp  rdx, rdi\njl   loop\nexit:\nlea  rax, [rdi+rdi*2] ; n*3 in the hole of rax\nret\nloop:\nmov  rcx, r8\nadd  rcx, rax\n...\nadd  rax, 0x8\ncmp  rdx, rdi\njl   loop\njmp  exit</code></pre>\n<p>The offset in <code>rax</code> is dead in the exit block, so <code>n*3</code> (one\n<a href=\"https://www.felixcloutier.com/x86/lea\" rel=\"nofollow ugc noopener\"><code>lea</code></a>) can use <code>rax</code>,\nwhich is also the return register. A free win from block order.</p>\n<h3 id=\"fixed-registers\">Fixed registers</h3>\n<p>rat numbers its registers 1 to 40, so one <code>U64</code> holds a set of them. Each slot gets one mask,\n<code>busy[slot]</code>. A set bit means that register is busy at that slot.</p>\n<p>The same backward walk marks the physical registers the code uses directly:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>use</th><th>register</th><th>busy</th></tr></thead><tbody><tr><td>incoming argument</td><td>argument register</td><td>until the copy that reads it</td></tr><tr><td>call argument</td><td>argument register</td><td>from the copy that sets it to the call</td></tr><tr><td>call</td><td>all caller-saved</td><td>in the two slots of the call</td></tr><tr><td>return value</td><td><code>rax</code></td><td>from the call to the copy that reads it</td></tr><tr><td><a href=\"https://www.felixcloutier.com/x86/idiv\" rel=\"nofollow ugc noopener\">division</a></td><td><code>rax rcx rdx</code></td><td>reads <code>rax rcx</code>, writes <code>rax rdx</code></td></tr></tbody></table></div>\n<p>The masks and ranges of <code>h</code>:</p>\n<pre><code>instr   0  1  2  3  4  5  6  7  8\nslot    rw rw rw rw rw rw rw rw rw\nrdi     #. .. .#### .. .. .. .. ..\nrsi     ####. .. ## .. .. .. .. ..\nrax     .. .. .. ####. .. .. .####\nothers  .. .. .. ## .. .. .. .. ..\nv1 x    .======. .. .. .. .. .. ..\nv2 y    .. .================ .. ..\nv3+v4 t .. .. .. .. .=========. ..</code></pre>\n<p><code>r</code> and <code>w</code> are the read and write slots. <code>#</code> is busy, <code>=</code> is a\nlive range and <code>.</code> is free. A bar continues across the gap between instructions. &quot;others&quot; is\nevery other caller-saved register.</p>\n<p>When a bundle gets a register, rat sets that register&#39;s bit in every slot of its live range. After that,\nvregs and fixed registers are bits in the same masks. Each group of 64 slots also has a summary mask, the\nOR of its 64 masks, so a long range can skip 64 slots at a time.</p>\n<h3 id=\"coalescing\">Coalescing</h3>\n<p>A copy between two vregs of the same class is a candidate for\n<a href=\"https://en.wikipedia.org/wiki/Register_allocation#Coalescing\" rel=\"nofollow ugc noopener\">coalescing</a>. These\ncopies come from:</p>\n<ul><li>two-address instructions</li><li><p><a href=\"https://en.wikipedia.org/wiki/Static_single-assignment_form\" rel=\"nofollow ugc noopener\">phi nodes</a>: a</p><p>value that comes from different blocks at a join point</p></li></ul>\n<p>If the two live ranges do not overlap, the vregs become one bundle. It has the merged segments and the summed\nweight. rat deletes a copy inside one bundle. In\n<code>h</code>, <code>v3</code> is <code>[9, 10]</code> and <code>v4</code> is <code>[11, 14]</code>, so\nthey merge.</p>\n<ul><li>rat sorts the copies by loop depth, deepest first. Hot copies merge before cold copies can block them.</li><li>The bundles are kept in a <a href=\"https://en.wikipedia.org/wiki/Disjoint-set_data_structure\" rel=\"nofollow ugc noopener\">union-find</a>.</li><li><p>A merge first walks both segment lists to check for overlap. rat skips a merge when the two bundles</p><p>together have more than 256 segments.</p></li></ul>\n<p>A copy between a vreg and a physical register sets a <strong>hint</strong> instead: the bundle prefers that register\nif it is free.</p>\n<h3 id=\"picking-registers\">Picking registers</h3>\n<p>Each bundle gets a priority:</p>\n<pre><code>priority = weight / sqrt(length in slots)</code></pre>\n<ul><li>Short, hot ranges come first: they matter most and are the easiest to place.</li><li>Long, cold ranges come last and get spilled.</li><li><code>sqrt</code> keeps a long loop counter from losing too much priority.</li></ul>\n<p>rat calls <code>pick</code> on each bundle in priority order:</p>\n<pre><code>// cls: register class, gp or xmm\nPhysReg pick(VReg v) {\n    U64 blocked = ~allocatable[cls];\n    for(auto [start, end] : segs[v])\n        for(I32 s = start; s &lt;= end; ++s)\n            blocked |= busy[s]; // or 64 at a time\n    if(hint[v] != kNoReg &amp;&amp; !(blocked &gt;&gt; hint[v] &amp; 1))\n        return hint[v];\n    // caller-saved first, callee-saved last\n    return firstFree(order[cls], blocked);\n}</code></pre>\n<h4 id=\"picking-in-h\">Picking in <code>h</code></h4>\n<div class=\"table-wrap\"><table><thead><tr><th>bundle</th><th>hint</th><th>gets</th></tr></thead><tbody><tr><td><code>v1</code></td><td><code>rdi</code></td><td><code>rdi</code></td></tr><tr><td><code>v3+v4</code></td><td><code>rax</code></td><td><code>rax</code></td></tr><tr><td><code>v2</code></td><td><code>rsi</code></td><td><code>rbx</code></td></tr></tbody></table></div>\n<p>In the diagram, <code>rdi</code> is busy only before and after <code>v1</code>, so <code>v1</code> gets it.\nBoth copies become <code>mov rdi, rdi</code>, and the\n<a href=\"https://en.wikipedia.org/wiki/Peephole_optimization\" rel=\"nofollow ugc noopener\">peephole pass</a> deletes them after\nallocation.</p>\n<p><code>v2</code> crosses the call. Every caller-saved register is busy in the call slots, so the first free\nregister is <code>rbx</code>, the first callee-saved one. The prologue saves\nonly the callee-saved registers rat used.</p>\n<p>On Linux, no xmm register is callee-saved, so a float that crosses a call always goes to\nthe stack.</p>\n<h3 id=\"spilling\">Spilling</h3>\n<p>A bundle with no free register is spilled for its full lifetime. Then:</p>\n<ol><li>rat sorts the spilled bundles by start. It reuses a stack slot when the last bundle in it has ended.</li><li>rat rewrites the code. A vreg whose bundle got a register becomes that register.</li><li><p>Before each instruction, rat loads each spilled operand into a temporary register. After it, rat stores</p><p> each spilled result.</p></li></ol>\n<p>The temporary is <code>r10</code> or <code>r11</code> (<code>xmm14</code> or <code>xmm15</code> for floats).\nNo bundle ever gets these. If both are busy, rat takes the first register free at that\ninstruction.</p>\n<p>Two cases need no temporary. A copy between a register and a spilled bundle becomes the load or the store\nitself. A call reads a spilled stack argument from its stack slot directly.</p>\n<p>In <code>p</code>, 14 values are live at once:</p>\n<pre><code>void p(long* a) {\n    long x0 = a[0], x1 = a[1], ..., x13 = a[13];\n    a[0] = x0 * x13; a[1] = x1 * x12; a[2] = x2 * x11;\n    a[3] = x3 * x10; a[4] = x4 * x9;  a[5] = x5 * x8;\n    a[6] = x6 * x7;\n}</code></pre>\n<p>16 registers minus <code>rsp</code>, <code>rbp</code>, <code>r10</code>, <code>r11</code> and\n<code>rdi</code> (which holds <code>a</code>) leaves 11 for 14 values. <code>x0</code>-<code>x6</code> also\nhold the products (two-address\n<a href=\"https://www.felixcloutier.com/x86/imul\" rel=\"nofollow ugc noopener\"><code>imul</code></a>), so they have more\nuses. Of <code>x7</code>-<code>x13</code>, the three with the longest ranges go to the stack.</p>\n<p>Before the peephole pass:</p>\n<pre><code>mov  r12, [rdi+0x30]  ; x6, in a register\nmov  r10, [rdi+0x38]  ; x7, spilled\nmov  [rbp-0x8], r10\nmov  r10, [rdi+0x40]  ; x8, spilled\nmov  [rbp-0x10], r10\nmov  r10, [rdi+0x48]  ; x9, spilled\nmov  [rbp-0x18], r10\n...\nmov  r10, [rbp-0x18]  ; reload x9\nimul r9, r10\nmov  r10, [rbp-0x10]  ; reload x8\nimul rbx, r10\nmov  r10, [rbp-0x8]   ; reload x7\nimul r12, r10</code></pre>\n<p>No instruction between the store of <code>x9</code> and its reload writes <code>r10</code>. So the peephole\npass deletes the reload. Then nothing reads that stack slot, so it also deletes the store.</p>\n<h3 id=\"it-can-be-dumb\">It can be dumb</h3>\n<p>Without eviction, an early decision is final. Here is the case that annoys me most:</p>\n<pre><code>long sum(long* a, long n) {\n    long s = 0;\n    for(long i = 0; i &lt; n; ++i)\n        s += a[i];\n    return s;\n}</code></pre>\n<p>rat compiles it to:</p>\n<pre><code>mov  r9, rdi          ; a: rdi was taken by a[i]\nmov  r8, rsi          ; n: rsi was taken by s\n...\nexit:\nmov  rax, rsi         ; s: rax was taken by a+8*i\nret\nloop:\nmov  rax, r9\nadd  rax, rcx         ; rax = a + 8*i\nmov  rdi, [rax]       ; rdi = a[i]\nadd  rsi, rdi\n...</code></pre>\n<p>The loop values are short and hot, so they go first:</p>\n<ol><li>The address <code>a+8*i</code> takes <code>rax</code>.</li><li><code>s</code> loses its hint <code>rax</code> and takes <code>rsi</code>.</li><li><code>a[i]</code> takes <code>rdi</code>.</li><li><code>a</code> and <code>n</code> come last and lose their hints too.</li></ol>\n<p>The result is three movs, all outside the loop.<a href=\"#fn5\">5</a> An allocator with\neviction would fix this chain. I decided three cold movs are not worth the extra code.</p>\n<h3 id=\"numbers\">Numbers</h3>\n<p>Against the old allocator:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>metric</th><th>change</th></tr></thead><tbody><tr><td>allocator source</td><td>-58%</td></tr><tr><td>instructions emitted</td><td>-3.6%</td></tr><tr><td>stores emitted</td><td>-22%</td></tr><tr><td>allocator time, <a href=\"https://www.sqlite.org/amalgamation.html\" rel=\"nofollow ugc noopener\">sqlite</a> at -O0</td><td>-77%</td></tr><tr><td>total compile time, sqlite at -O0</td><td>-43%</td></tr></tbody></table></div>\n<h4 id=\"what-each-feature-was-worth\">What each feature was worth</h4>\n<p>Before the rewrite, I turned off each old feature in turn and measured the\ncode. This was the most useful hour of the project:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>feature</th><th>instructions saved</th><th>new allocator</th></tr></thead><tbody><tr><td>copy coalescing and copy hints</td><td>about a third</td><td>kept</td></tr><tr><td>live range holes</td><td>10%</td><td>kept</td></tr><tr><td>spill choice by use weight</td><td>5.5%</td><td>kept</td></tr><tr><td>hints to physical registers</td><td>1%</td><td>kept</td></tr><tr><td>optimistic second try at spilled ranges (37% of allocator time)</td><td>0.01%</td><td>dropped</td></tr><tr><td><a href=\"https://en.wikipedia.org/wiki/Rematerialization\" rel=\"nofollow ugc noopener\">rematerialization</a> (recompute instead of reload)</td><td>not measurable</td><td>dropped</td></tr><tr><td>spill slot cache</td><td>not measurable</td><td>dropped</td></tr></tbody></table></div>\n<h3 id=\"wrapping-up\">Wrapping up</h3>\n<p>No eviction, no splitting, no second pass, and the new allocator still beats the old one. Most of the\nquality comes from cheap things: coalescing, hints, holes and use weights.</p>\n<p>The lesson for me: measure the old code before I port it. Much of the old allocator did nothing.</p>\n<h3 id=\"references\">References</h3>\n<ul><li>Poletto and Sarkar, <a href=\"https://dl.acm.org/doi/10.1145/330249.330250\" rel=\"nofollow ugc noopener\">Linear scan   register allocation</a>: the base of the old allocator.</li><li>Max Bernstein, <a href=\"https://bernsteinbear.com/blog/linear-scan/\" rel=\"nofollow ugc noopener\">Linear scan register   allocation on SSA</a> and <a href=\"https://bernsteinbear.com/blog/linear-scan-lifetime-holes/\" rel=\"nofollow ugc noopener\">Linear scan with lifetime holes</a>: a readable pair of posts.</li><li><p>Jakob Stoklund Olesen,</p><p><a href=\"https://blog.llvm.org/2011/09/greedy-register-allocation-in-llvm-30.html\" rel=\"nofollow ugc noopener\">Greedy   register allocation in LLVM 3.0</a>: the big version of this idea, with eviction and splitting.</p></li><li>Chris Fallin, <a href=\"https://cfallin.org/blog/2022/06/09/cranelift-regalloc2/\" rel=\"nofollow ugc noopener\">Cranelift,   part 4: a new register allocator</a>: a long, good read on bundles.</li><li>Matt Keeter, <a href=\"https://www.mattkeeter.com/blog/2022-10-04-ssra/\" rel=\"nofollow ugc noopener\">The solid-state   register allocator</a>: even smaller, it runs in one backward pass.</li></ul>\n<h3 id=\"notes\">Notes</h3>\n<ol><li><p>On Windows, only <code>xmm0</code>-<code>xmm3</code> can be used. <code>xmm4</code> and</p><p> <code>xmm5</code> are the spill temporaries, and rat does not use the callee-saved\n <code>xmm6</code>-<code>xmm15</code>. <a href=\"#ref1\">[back]</a></p></li><li><p><a href=\"https://en.wikipedia.org/wiki/Chaitin%27s_algorithm\" rel=\"nofollow ugc noopener\">Chaitin et al.</a></p><p> showed that any graph can be the interference graph of some program. So register allocation is at least as\n hard as graph coloring. <a href=\"#ref2\">[back]</a></p></li><li>Cranelift&#39;s <a href=\"https://cfallin.org/blog/2022/06/09/cranelift-regalloc2/\" rel=\"nofollow ugc noopener\">regalloc2</a> uses the same word for the same idea. <a href=\"#ref3\">[back]</a></li><li><p>Each block stores the last vreg that marked it, so the walk never clears a visited</p><p> array. <a href=\"#ref4\">[back]</a></p></li><li><p>The mov inside the loop, <code>mov rax, r9</code>, has a different</p><p> cause. It is the two-address copy for the add, and it always stays. <a href=\"#ref5\">[back]</a></p></li></ol>","headings":[{"level":1,"text":"rat's register allocator","id":"rat-s-register-allocator"},{"level":3,"text":"Five steps","id":"five-steps"},{"level":3,"text":"Live ranges","id":"live-ranges"},{"level":3,"text":"Fixed registers","id":"fixed-registers"},{"level":3,"text":"Coalescing","id":"coalescing"},{"level":3,"text":"Picking registers","id":"picking-registers"},{"level":3,"text":"Spilling","id":"spilling"},{"level":3,"text":"It can be dumb","id":"it-can-be-dumb"},{"level":3,"text":"Numbers","id":"numbers"},{"level":3,"text":"Wrapping up","id":"wrapping-up"},{"level":3,"text":"References","id":"references"},{"level":3,"text":"Notes","id":"notes"}]}}