Computer Architecture · Instruction Sets

Two-Operand Encodings and the Destructive Write Problem

Why the second source register disappears in a two-operand format, how destructive writes constrain register allocation, and what a three-operand format buys back.

An instruction that computes a + b must name the two sources and the destination. If the format has room for three register fields, all three are independent. If it has room for two, one field must serve double duty, and the operation becomes a = a + b: the destination is also a source, and the previous value of a is destroyed. The instruction is shorter and the encoding is cheaper, and every piece of code that uses it carries a constraint that a three-operand machine does not have.

x86 has used the two-operand form throughout its history. AArch64 and RISC-V use three-operand forms for their register-register arithmetic. The difference is not cosmetic, and it shows up in register pressure, in the number of move instructions a compiler emits, and in the ability of the hardware to extract parallelism from a straight-line sequence.

Where the third field goes

The arithmetic formats of a fixed-width ISA have a fixed budget: seven opcode bits, three register fields of five bits each, and two function fields. That is 32 bits exactly for RISC-V’s R-type, with no immediate at all. A two-operand format could reclaim five bits — enough for a five-bit immediate or a wider function field — and architectures that chose the two-operand form did so for reasons that trace to the era of their design rather than to a deliberate trade.

x86’s encoding is not field-based in the same way. Its operands are specified by a ModR/M byte whose three-bit register field names either one register and one memory operand or two registers, plus an optional SIB byte for a second register in the memory operand. A add r/m32, r32 names the destination-or-memory operand in the ModR/M r/m field and the source in the reg field. There is no third field, and the destination is always one of the two operands.

The consequence in x86 is that a non-destructive add requires a copy: mov eax, ebx followed by add eax, ecx. The copy is often eliminated by the register renamer, which recognises a move and maps the destination architectural register to the same physical register as the source without executing anything. This is the mechanism that makes the two-operand form tolerable in practice: the instruction is present in the encoding, but it costs no execution slot and no register-file write on a core with move elimination.

Move elimination does not remove the encoding cost. The instruction still occupies fetch and decode bandwidth, and it still consumes an entry in the reorder buffer, which is why x86 front ends are built to handle a high proportion of moves. It also does not apply when the move is not a simple register-to-register copy — a move from memory, or a move that is part of a longer dependency chain through a partial register — and those cases pay in full.

What destructive writes do to the allocator

A compiler’s register allocator assigns architectural registers to the values that are live at each point. With three-operand instructions, the destination of an operation can be a fresh register, so the two sources remain available for later uses. With two-operand instructions, the destination must be one of the sources, so the allocator must either prove that the source is dead after this use or emit a copy.

The proof is not always available. In x = a + b; y = a + c; the value a is live after the first addition, so the first addition cannot overwrite a. A three-operand ISA emits add x, a, b and add y, a, c with no copies. A two-operand ISA must copy a into x first, then add b, then copy a into y and add c. The two copies are candidates for move elimination, but the allocator cannot rely on the hardware to remove them, because a different implementation might not.

/* The pattern that a two-operand ISA turns into copies. */
int f(int a, int b, int c)
{
    int x = a + b;
    int y = a + c;
    return x * y;
}

The allocator’s standard response is to prefer assigning the destination to a source that is dead, which requires knowing the liveness precisely, and to insert copies where it cannot. Copies increase the number of instructions and the register pressure, because a copy’s destination is a new live range that overlaps the source’s until the copy completes. This is a second-order effect that compounds: more live ranges means more spills, and spills mean memory traffic that the three-operand machine does not incur.

Why the two-operand form survived

The form’s persistence is not an accident of history. It has three genuine advantages.

The first is encoding density. A two-operand instruction needs one fewer register field, which is space that can hold an immediate, a condition selector, or a wider opcode. x86 uses the saving for the ModR/M and SIB structure that makes memory operands possible, which is a large part of why its code density is competitive despite variable length.

The second is that the destructive form matches the accumulator idiom that early machines were built around. A single-accumulator machine has one register and every operation is destructive by definition. When the register file grew, keeping the destination equal to a source preserved the property that a chain of operations needs no extra registers, which is what allowed the first 8-bit and 16-bit compilers to generate code for machines with very few registers.

The third is that the form is convenient for the hardware’s write port. An instruction that reads two registers and writes one, where the written register is one of the read ones, needs two read ports and one write port. A three-operand instruction that reads two and writes a third needs the same two read ports and one write port, so the operand format does not change the port count; what it changes is only which architectural register a later instruction must wait for, and a renamer handles that.

Three operands and the cost of the extra field

A three-operand format spends five bits on the destination and gets back the ability to name a fresh register. On a fixed-width ISA those five bits have to come from somewhere, and the usual source is the immediate field: a three-operand register-register format has no immediate, and a register-immediate format has only one source register.

The trade is visible in the instruction mix. A three-operand ISA needs a separate move instruction for the cases where a value must be copied, and it has one — but the compiler emits far fewer of them. A two-operand ISA needs no move for the common a += b pattern but needs one for c = a + b when a is live afterwards. The relative frequency of the two patterns in real code decides which machine executes fewer instructions, and the frequency depends on the language and the compiler’s expression scheduling.

The three-operand format also changes what the compiler can do with a single instruction. A fused multiply-add that computes d = a * b + c needs four register fields if none of the sources may be overwritten, more than the three a normal register-register format provides. Fixed-width ISAs accommodate this with a separate format rather than by stealing bits from an existing one: RISC-V’s four-register R4 type drops the immediate field and spends the freed bits on a third source, so its fmadd leaves all four operands distinct, and AArch64 fits four five-bit register fields into the same thirty-two-bit word and does the same. The destructive write appears where the format provides only three operand slots. x86’s fused multiply-add encodings take three operands and make the destination double as one of the multiplicands; three orderings are specified so that the compiler can choose which source is sacrificed.

Encoding the accumulator away

Some architectures provide both forms and let the compiler choose. x86’s imul has a two-operand form and a three-operand form with an immediate: imul r32, r/m32, imm32 computes the destination from a source and a constant without destroying the source. The three-operand form exists because the immediate occupies the place the second register would have taken, and the destination is a separate field. It is the exception that shows the rule: the third field appears when the source is not a register.

ARM’s Thumb encodings show the compression pressure directly. The 16-bit Thumb arithmetic instructions are largely two-operand, with the destination restricted to the low eight registers, because sixteen-bit encodings cannot afford three full register fields. The 32-bit Thumb-2 encodings restore the three-operand form for the cases that need it. The architecture’s designers chose to have both, at the cost of a decoder that must handle two instruction lengths and two operand conventions, which is the same trade discussed in Fixed-Width Instruction Encoding and Where the Fields Go.

The failure that is not a bug

The destructive write has a class of failure that looks like a compiler bug and is not. A value that is live across an instruction that overwrites its register is destroyed, and the register allocator is responsible for not allowing that. When the allocator is wrong, the symptom is a wrong value used later, often at a distance, and often only on one of the paths through the function. The bug is in the allocator rather than in the instruction, but the instruction set made the mistake possible by giving one field two meanings.

Inline assembly is where this becomes a user-visible problem. A constraint that tells the compiler an operand is an input does not tell it that the instruction also writes that register, and an assembly block that destroys an input without declaring it corrupts whatever the compiler had placed there. Every assembler’s constraint language has a way to express read-write operands for this reason, and using an input-only constraint for an instruction that writes its source is a defect that survives testing whenever the register happens to be dead.

The two-operand form is therefore not a defect of the architectures that use it. It is a choice about where to spend encoding bits, and its cost is paid in the compiler and in the front end rather than in the arithmetic itself. The three-operand form spends bits to remove that cost, and the architectures that adopted it did so when fixed-width encodings made the bits available and when compilers became good enough to exploit the freedom.

References

  1. Intel 64 and IA-32 Architectures Software Developer Manuals, Volume 2 — Intel Corporation
  2. Arm Architecture Reference Manual for A-profile architecture — Arm Limited
  3. The RISC-V Instruction Set Manual, Volume I: Unprivileged ISA — RISC-V International