Who Moved My Scratch Register? Teaching an Emitter to Borrow and Return

Replace informal scratch-register conventions with explicit borrowing and lifetime rules.

Posted by Bruce Lee on 2026-04-20

Series contents · Operators and Layouts · 阅读中文版

Two code-generation functions work beautifully in isolation. Combine them, and the result suddenly breaks. The first says, “I only borrowed a scratch register for a base address.” The second says, “So did I.” They borrowed the same one, and neither asked when the other would return it.

Register names as strings are wonderfully convenient: short to write and direct to read. They also express no ownership. A string cannot tell you whether that name currently holds an address, loop count, or intermediate value that is still needed. As kernels begin calling shared emission helpers, “default scratch registers” once scattered across files meet in the same instruction stream.

How a Temporary Value Gets Overwritten

A teaching failure can be reduced to:

1
2
3
4
outer:
temp = base_address
emit_helper() # helper also uses temp
load_from(temp) # assumes it still contains the original base_address

The host C++ variable scope is fine; the generated machine program is already broken. A compiler backend lives on two timelines: C++ executing now and instructions executing later. Debugging requires asking whether a variable contains a register handle or the register’s future value.

Scopes and Register Ownership

The historical fix chose a practical middle ground. The emitter maintains a finite scratch pool and returns a noncopyable, movable object when a register is requested. Leaving scope returns the register. Fixed-role registers come through a separate entry point and do not consume the scratch pool. Callers no longer hard-code “I want scratch name number such-and-such.” They declare “I need one for this scope.”

Teaching pseudocode unrelated to the original interface expresses the idea:

1
2
3
4
with emitter.borrow_scratch() as address:
emit_load_immediate(address, descriptor_location)
emit_read_descriptor(address)
# The scratch slot associated with address can now be reused by later generated code.

The scope should cover every use of the value in the generated instruction sequence. End it too early, and a later helper can reuse the register; end it too late, and simultaneous occupancy grows unnecessarily. In a kernel that finishes loading addresses and parameters before starting a loop, the load base and loop counter may not need to be live together. Narrowing the former’s scope allows the latter to reuse its scratch slot safely.

That safety comes from static emission order and calling conventions, not magic in the handle object. If generated control flow jumps away and later returns to read the old register value, early destruction of the host object may still be wrong. A scoped pool suits disciplined local emission. Complex live ranges across blocks need stronger dataflow analysis or a more explicit register-reservation protocol.

Why prohibit copying? If two objects both believe they own one slot, destruction can release it twice. Worse, one object can release it, the pool can assign it to a third user, and the surviving copy can then release a slot that is active again. Such bugs often hide on the normal path and appear during container growth or exception unwinding.

Moving transfers ownership instead. The moved-from object must become empty and stop releasing the resource. Move assignment must release the destination’s old slot before taking ownership from the source. The historical implementation explicitly handled both. This is C++ resource management serving a concrete backend correctness need: maintaining compile-time state about which register names can be reused.

Why represent fixed registers separately? Registers with fixed roles such as zero, stack pointer, or global base must not be casually borrowed because scratch space is tight. A fixed handle describes a name or permission to use it; it does not mean that the scratch pool has reserved it. In particular, if the interface can return as “fixed” a register also usable as scratch, the caller must still avoid collisions with pool allocations.

That is the boundary of a type or wrapper. Putting a string in a class improves expression, but if the name remains exposed for arbitrary use, constraints are still necessary. This is a step from convention toward a checkable interface, not proof that “register conflicts are now impossible.” Stating the capability modestly helps readers reuse it correctly.

Exhaustion, Validation, and Costs

What happens when the pool is exhausted? The historical code throws a clear error rather than silently reusing a live slot. For local code generation, that is more reliable than emitting an incorrect program. A fuller system might support saving and restoring registers or spilling to the stack, but those introduce address, alignment, calling-convention, and performance concerns. They cannot be completed by adding a casual fallback when the pool runs out.

An exhaustion test should allocate up to capacity and verify that the next request fails. After a release, a new request should recover the slot. Tests should also cover inner-scope returns while outer handles remain active, move construction, move assignment, exception unwinding, and fixed registers not unexpectedly consuming pool state. Historical tests covered capacity, scoped returns, reuse, and several fixed-register behaviors. More comprehensive combinations of move cases are recommendations here.

Why did this issue appear in an Abs-related commit? A small kernel is a good proving ground for an emitter abstraction: load addresses and parameters, then issue a vector operation, with an instruction structure that is easy to inspect. A commit title may name a test case while the engineering capability reaches every kernel that might reuse the emitter. Reading only the title misses that design layer.

Verification can be divided into two kinds. One checks host resource state: simultaneously borrowed handles have different names, and returned slots can be reused. The other checks generated programs: a loading helper must not clobber an outer register that is still needed afterward. Testing resource destruction alone, without inspecting the instruction stream, cannot prove nested emission correct.

This also affects binary regressions. A kernel should preserve semantics even if it uses different scratch registers. A test fixing every output byte may require frequent golden updates for valid allocation changes. More stable verification can focus on necessary dependencies and important encoding fields, with a smaller number of exact goldens locking down instruction formats. Whole-stream goldens are useful, but their constraints on implementation details should be understood.

Performance does not necessarily improve. A local pool may reduce unnecessary save-and-restore operations, or conservative scopes may cause exhaustion that limits more complex generation. Its directly verifiable benefits are clearer ownership and earlier exposure of conflicts. Runtime-performance claims require examining final instruction counts, temporary live ranges, and save-and-restore traffic, not treating C++ object counts as hardware overhead.

One engineering experiment would choose kernels that nest descriptor loads and generate two-level loops, print each borrow and return event alongside emitted instruction positions, and check that handle lifetimes cover uses in the target program. This builds a shared language more effectively than repeatedly asking why a register changed again.

Branches, Reentrancy, and Failure Recovery

Extending the pool also requires answering “who owns the emitter?” If a handle references the pool, that pool must outlive every active handle. Returning a temporary handle into a scope longer-lived than the emitter, or destroying it after its owner, violates the relationship. A type system can constrain some uses; documentation and tests should state the remaining requirements. The historical code implemented basic ownership transfer, not a formally verified borrowing system.

Branch emission provides another exercise. Host code may generate an if’s true branch and then its false branch. The branches are mutually exclusive at runtime, yet their host handles may coexist during generation. A conservative pool treats both as live and creates unnecessary register pressure. Conversely, a value needed after a branch merge cannot be released merely because a host generation function returns. This explains both the usefulness and the limits of a local scoped pool: it uses host structure as an approximation to target-program liveness.

To reduce pressure, first narrow the scopes of handles used only for configuration loading, then allocate longer-lived loop counters. Independent loading stages can also be helpers that do not leak handles. But freeing slots must never mean releasing an object that later emitted instructions will still reference. The criterion for shortening scope is the target program’s last use, not tidier-looking host code.

Threading and reentrancy need explicit conventions too. Parallel use of one emitter requires synchronization of its active-register set or a prohibition on concurrency. Even with an emitter per thread, a static label counter may affect determinism or create a race. Related historical kernels contain static loop numbering, which is a review clue, not proof of a race without knowing the calling model. One reasonable proposal is to put unique-label generation under emitter ownership, aligning lifetime and concurrency boundaries.

Error handling should preserve pool state. Handles borrowed before an allocation fails should return automatically during unwinding. After move assignment releases the old resource, ownership of the new one must remain unique. A teaching test can deliberately throw inside nested scopes, then request the same number of slots to check for leaks. If the emitter already appended some instructions before the exception, rolling back that instruction stream is a separate transaction question. Returning registers does not cancel half a kernel.

These discussions also clarify maintainability. A pool may be only a few dozen lines long, yet it turns conventions previously held in kernel authors’ heads into observable state. Reviewers can ask about live counts, release points, fixed roles, and exhaustion behavior without memorizing which file defaults to which temporary name. Its first value is easier reasoning. Whether it reduces instructions or enables more complex fusion should be investigated on that clearer foundation.

One of the most useful comments in an emitter may now be: “This temporary value is no longer needed here.” Marking the last use supports correctness and leaves room for future optimization. Borrowing is easy; knowing when something can be returned is basic backend etiquette.


Series contents · Previous · Next


If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !