Calling conventions
A parameter is a language-level idea and a register is a machine-level one. The function that maps the first onto the second is the calling convention, and it is the point where a type stops being a description and starts costing something.
Parameters, arguments, and the binding between them¶
The two words name opposite ends of one relationship. A parameter is an object declared by a function definition, which acquires a value on entry. An argument is an expression in the parentheses of a call. The teaching vocabulary calls these formal and actual parameters; the standard's terms are more useful because they say what each one is rather than where it sits.
For a call f(a₁, …, aₙ) against a prototype declaring parameters
p₁, …, pₙ of types T₁, …, Tₙ, the call performs
pᵢ ← convert(Tᵢ, aᵢ) for every i, each conversion as if by
assignment. Three consequences follow directly, and each one is a bug class in practice:
- A parameter is an object, so it is assignable. Writing to one is legal and local; the caller never sees it. In C there is exactly one passing mechanism — by value — and “pass by reference” means passing a pointer by value. The pointer is copied; what it points at is not.
- An argument is an expression, so it is evaluated, and the order of evaluation between
arguments is unspecified and unsequenced. The call is a function of the resulting values, but the
evaluation is an arbitrary permutation of the argument subexpressions, which is why
f(i++, i++)is undefined rather than merely implementation-defined. - The correspondence is by position and compatible type, not by identity. A parameter declared with array
or function type is adjusted to a pointer, so
void f(int a[10])andvoid f(int *a)declare the same function andsizeof ainside is the size of a pointer. The10is a comment that the compiler is allowed to ignore.
Everything below is about what happens after that binding is settled: a total function from a sequence of
types to a sequence of machine locations, fixed by an ABI rather than by the language. The language side of
the same boundary — declarations against definitions, the four shapes a function type can take, what a
return actually copies, and why recursion needs no support from the language — is the
C article’s section on functions.
The C standard removed the last escape from this correspondence in C23: an empty parameter list
() now means (void) rather than “unspecified arguments”, and
old-style definitions are gone. Until then a declaration could decline to describe its own parameters, and
the compiler had nothing to check a call against.
Classification as a join over a chain¶
The System V AMD64 ABI does not ask where a value goes. It asks what class each aligned eight-byte
window of the type belongs to, and the register follows from the class. An aggregate larger than two
eightbytes is MEMORY immediately; otherwise every field contributes its class to the eightbyte
it lands in, and the eightbyte takes the merge of them.
The merge rule is the interesting part, because it is a join on a totally ordered set. Order the classes
NO_CLASS ⊑ SSE ⊑ INTEGER ⊑ MEMORY
and the specification's merge is exactly max under that order: merging with
NO_CLASS yields the other operand, anything merged with MEMORY yields
MEMORY, and a mixed INTEGER/SSE eightbyte becomes
INTEGER.
| Merge | NO_CLASS | SSE | INTEGER | MEMORY |
|---|---|---|---|---|
NO_CLASS | NO_CLASS | SSE | INTEGER | MEMORY |
SSE | SSE | SSE | INTEGER | MEMORY |
INTEGER | INTEGER | INTEGER | INTEGER | MEMORY |
MEMORY | MEMORY | MEMORY | MEMORY | MEMORY |
The class merge, tabulated. Source: System V AMD64 psABI, section 3.2.3, rule 4.
The table is symmetric, its diagonal is the identity, and NO_CLASS and MEMORY are
the identity and absorbing elements respectively.
MEMORY is absorbing: one MEMORY eightbyte sends the whole aggregate to the stack. Source: System V AMD64 psABI, section 3.2.3, post-merger cleanup.Because the operation is associative, commutative, and idempotent, the class of an eightbyte is independent of the order in which its fields are visited. That is a stronger statement than it sounds: it means declaration order cannot change an eightbyte's class. What declaration order can change is which eightbyte a field lands in, because that is decided by its byte offset, and padding moves offsets. So reordering the fields of a struct can change how it is passed — but only by moving fields across the eight-byte boundary, never by changing how a boundary's occupants combine.
The absorbing element does the rest of the work. One MEMORY eightbyte anywhere sends the whole
aggregate to the stack, which is why a single misaligned or oversized member can quietly demote an otherwise
register-friendly type.
Five aggregates are enough to exercise every outcome the rule can produce, and the second column is the only input it reads.
| Type | Members | Bytes | System V location | Microsoft x64 location |
|---|---|---|---|---|
Small8 |
{ int a, b; } |
8 | rdi, both fields packed | rcx, both fields packed |
Packet16 |
{ int a, b; double c; } |
16 | rdi + xmm0 | by address in rcx |
Vec2 |
{ double x, y; } |
16 | xmm0 + xmm1 | by address in rcx |
Odd12 |
{ int a, b, c; } |
12 | rdi + esi | by address in rcx |
Vec3 |
{ double x, y, z; } |
24 | caller's stack frame | by address in rcx |
Where each aggregate's parameter actually arrived, read from the callee compiled for
both targets. Odd12 spans two eightbytes and is passed in two integer registers even though its
size is not a power of two; Vec3 exceeds two eightbytes and is MEMORY.
Source: data/abi/classify.c.
Assignment: a scan, or a map¶
Once every argument has a class, the two dominant x86-64 conventions differ in a way that is easy to state
precisely. System V keeps two counters, one for integer registers and one for vector registers, and assigns
each argument the next free register of its own class. Microsoft x64 keeps one index and assigns
argument i to slot i, choosing the integer or vector register of that slot by the
argument's type and burning the other.
So under System V the location of argument i depends on the classes of all the arguments
before it: it is a left fold over the argument list, a prefix scan whose state is a pair of counters in
ℕ². Under Microsoft x64 the location depends only on i and on that
argument's own class: it is a pointwise map. One is history-dependent, the other is not.
Six alternating scalars are the shortest program that separates them.
| Parameter | Type | System V | Microsoft x64 | Note |
|---|---|---|---|---|
a |
double |
xmm0 |
xmm0 |
slot 1 |
b |
int |
edi |
edx |
slot 2 |
c |
double |
xmm1 |
xmm2 |
slot 3 |
d |
int |
esi |
r9d |
slot 4 |
e |
double |
xmm2 |
stack, [rsp+40] |
spilled |
f |
int |
edx |
stack, [rsp+48] |
spilled |
Locations for double six(double a, int b, double c, int d, double e, int f),
read from the callee compiled for both targets. Source: data/abi/classify.c.
System V places all six in registers and the caller can tail-call straight into it. Microsoft x64 runs out
after four and spills the last two, even though its integer and vector files are each only half used —
rcx, r8, xmm1 and xmm3 are all free and all unusable,
because their slots were consumed by arguments of the other class.
Capacity as a vector against a scalar¶
The counters make register capacity a vector under System V and a scalar under
Microsoft x64. System V admits any argument list whose class census fits componentwise under
(6, 8) — up to fourteen register-passed scalars. Microsoft x64 admits any list of at most
four, whatever the classes. The Windows limit is not four integer and four floating-point registers; it is
four registers.
The same rule explains a Windows detail that otherwise looks arbitrary: for a variadic call a
floating-point argument is placed in both the vector register and the integer register of its slot.
If the slot owns both, duplicating costs nothing and the callee can read whichever it needs without knowing
the type. System V instead sets al to the number of vector registers used, because with two
independent counters there is no single slot to duplicate into.
The Microsoft size predicate¶
Microsoft x64 has no aggregate classification at all. A struct is passed directly in one register when
sizeof(T) ∈ {1, 2, 4, 8}, equivalently sizeof(T) = 2k with k ≤ 3
and by hidden reference otherwise: the caller materialises a temporary copy and passes its address. The predicate is not monotone in size, which produces a result worth stating outright — padding a three-byte struct up to four bytes improves how it is passed, from an address to a register. Growing a type can make it cheaper.
| Struct | sizeof | System V, first instruction | Microsoft x64, first instruction | Windows passing |
|---|---|---|---|---|
{ char a; } |
1 | movsx eax, dil |
movsx eax, cl |
In a register |
{ short a; } |
2 | movsx eax, di |
movsx eax, cx |
In a register |
{ char a, b, c; } |
3 | movsx ecx, dil |
movsx edx, byte ptr [rcx] |
By address |
{ int a; } |
4 | mov eax, edi |
mov eax, ecx |
In a register |
{ int a; char b; } |
8 | mov rax, rdi |
mov rax, rcx |
In a register |
{ double a; } |
8 | ret |
movq xmm0, rcx |
In a register |
{ double a; char b; } |
16 | movsx eax, dil |
movsx eax, byte ptr [rcx + 8] |
By address |
{ double a, b; } |
16 | addsd xmm0, xmm1 |
movsd xmm0, qword ptr [rcx] |
By address |
The callee's first instruction under each ABI, for eight structs spanning the
decision boundary. An operand of the form [rcx] means only the address arrived.
Source: data/abi/classify.c compiled with clang 18 for both targets.
Two rows repay a second look. { double a; } is eight bytes, so Windows passes it directly
— in rcx, an integer register, forcing a movq xmm0, rcx the callee
would not have needed for a bare double. Wrapping a float in a struct changes its register class
on Windows and does not on Linux. And { double a, b; } compiles to a single
addsd xmm0, xmm1 under System V, with no memory traffic whatsoever, against two loads through a
pointer under Microsoft x64.
Return values classify by the same rules, in reverse, and this is where the two conventions
agree most closely: a result too large for the available registers is written through a hidden pointer the
caller supplies. Under System V that pointer arrives in rdi and under Microsoft x64 in
rcx, in both cases displacing every declared parameter by one slot. A function whose source
declares one parameter can have two at the machine level, and nothing in the C makes that visible.
What the copy costs¶
Because a by-value parameter is a fresh object initialised from the argument, an aggregate argument means the bytes must exist twice. For a type in registers that costs nothing. For a type that is not, the caller stages a copy and the callee reads it back.
| Bytes | By value, ns/call | By pointer, ns/call | Difference | Run-to-run spread | Difference in bare calls | Copy strategy chosen |
|---|---|---|---|---|---|---|
| 16 | 1.21 | 0.92 | 0.30 | ±0.03 | 0.3 | two register moves, no copy |
| 32 | 7.62 | 0.91 | 6.71 | ±0.15 | 7.3 | 2 inline 16-byte stores |
| 64 | 7.60 | 1.21 | 6.39 | ±0.13 | 7.0 | 4 inline 16-byte stores |
| 128 | 7.88 | 0.91 | 6.98 | ±0.45 | 7.4 | 8 inline 16-byte stores |
| 192 | 8.52 | 0.91 | 7.61 | ±0.45 | 8.0 | 12 inline 16-byte stores |
| 256 | 9.43 | 0.91 | 8.52 | ±0.22 | 9.3 | 16 inline 16-byte stores |
| 384 | 11.33 | 0.91 | 10.41 | ±7.72 | 11.3 | rep movsq, 48 qwords |
| 512 | 15.92 | 0.91 | 14.99 | ±7.53 | 16.4 | rep movsq, 64 qwords |
| 768 | 16.71 | 1.22 | 15.51 | ±4.83 | 17.0 | rep movsq, 96 qwords |
| 1024 | 17.22 | 1.21 | 16.01 | ±2.77 | 17.4 | rep movsq, 128 qwords |
Callees differing only in whether the aggregate or its address is the parameter;
both read one field. Median of nine runs of data/abi/cost.c, each run reporting the minimum of seven trials of
five million calls, gcc 13.3 -O2, Intel Xeon at 2.80 GHz nominal. Spread is the largest
difference minus the smallest across those nine runs. The last column divides by a bare no-argument call on
the same machine, measured the same way at 0.92 ns, so the shape of the table survives being read on a
different processor. The copy strategy column is read from the generated assembly, not inferred from the
timings.
rep movsq. The bars at 384 bytes and above are medians of an unstable measurement and should be read as positions, not values; the table above them carries the run-to-run spread. Median of nine runs of data/abi/cost.c, each reporting the minimum of seven trials, gcc 13.3 -O2, Intel Xeon at 2.80 GHz nominal.The by-pointer column is flat at roughly one nanosecond across a sixty-four-fold range of sizes, which is
the control the measurement needs: passing an address costs the same whatever it addresses. It sits barely
above a bare call taking no arguments at all, so essentially everything in the difference column is
attributable to the copy. Sixteen bytes is the one row where the difference is not a copy: two doubles arrive
in xmm0 and xmm1, so the caller pays two register moves and nothing touches
memory — a third of a bare call, and the closest this table comes to free.
One regime that fits, and one that will not hold still¶
Over the sizes the compiler copies with inline vector stores, the difference is linear in the size and tightly reproducible — the nine runs agree to within half a nanosecond at every point. Least squares gives
Δ(n) ≈ 6.07 + 0.0087 n ns (32 ≤ n ≤ 256, R² = 0.896)
or, in the machine-independent unit, about 6.6 bare calls plus one more for every 106 bytes. The slope inverts to roughly 115 bytes per nanosecond, which is the marginal cost of the bytes themselves. It is small against the intercept, and that is the finding: the dominant term is the fixed penalty for leaving the register path, not the byte count. Six nanoseconds is about seventeen cycles at the nominal clock, consistent with a store-to-load forwarding round trip — the caller writes the outgoing parameter area and the callee immediately reads it back — that a register-passed argument never pays at all. Doubling a 32-byte struct to 64 bytes costs almost nothing; making it 32 bytes instead of 16 costs over twenty times what the 16-byte version cost in total.
Above 256 bytes the compiler switches to rep movsq, the cost roughly doubles, and the
measurement stops being reproducible. Across nine runs of the same binary the 384-byte difference ranged from
9.61 to 17.33 ns and the 512-byte difference from 12.18 to 19.71 ns — spreads larger than the
entire first regime. The variation is between processes rather than within them: each run is the minimum of
seven trials and is internally stable, so the likely cause is per-process stack layout changing the alignment
of the outgoing parameter area against the source it is copied from.
No model is fitted to the second regime, and an earlier draft of this page that fitted one was wrong to. Three runs are enough to produce a convincing straight line through numbers that move by seven nanoseconds when you run them again. What the second regime supports is an ordering — large aggregates cost distinctly more than small ones, and the copy strategy changes where the table says it does — and not a coefficient.
Why the obvious benchmark measures nothing¶
The first version of this measurement reported a ratio of 0.99 — passing 256 bytes by value apparently costing exactly what passing a pointer cost. The assembly explained it. The by-value callee had no side effects and its argument did not change across the loop, so the compiler inferred the function pure, hoisted the entire call out, and left a loop body of two floating-point additions, a decrement and a branch. The copy ran once rather than fifty million times, and the benchmark faithfully reported the cost of a program that no longer contained the thing being measured.
.L10:
addsd xmm1, xmm0
addsd xmm1, xmm0
sub rax, 2
jne .L10
Three changes fix it, and all three are load-bearing rather than defensive: the callees are marked so the compiler may not reason about their bodies across the call, the struct is mutated each iteration so the copy is not loop-invariant, and the result is stored to a volatile sink rather than accumulated, because an accumulator adds its own dependency latency to both sides of the comparison. Corrected, the same comparison puts by-value passing between six and eighteen times the cost of by-pointer for every aggregate of 32 bytes or more.
The general point outlives the example. A microbenchmark of a pure function is a measurement of whether the optimiser noticed, and the only way to know which was measured is to read what the compiler emitted.
Where this meets the rest of the atlas¶
A calling convention answers “where does this value live at a boundary”. The memory model answers “who reclaims it”. They are the two halves of what a value costs, and they interact: under reference counting the boundary is also where retain and release traffic is decided, so a convention that specifies whether the caller or the callee owns an argument is directly a performance decision for Swift and Objective-C. Under ownership, a move can be a register transfer with no copy at all, which is why Rust can pass by value where C++ would want a reference.
The C ABI is also the reason this page is about C at all. The C article’s treatment of the ABI layer covers the same boundary from the language side — why C has no name mangling, and why that absence made it the interchange format. Almost every language reaches other languages through it: it is the shared boundary that Python, Java, C#, Go and JavaScript all target when they leave their own runtime, so the classification rules above are the interoperation contract for the whole index, not a detail of one language.
| Language | Its relationship to the machine convention | Where it appears in the atlas |
|---|---|---|
| C | Defines the boundary everything else targets. The language has one passing mechanism and the platform ABI supplies the rest. | Functions, the ABI layer, manual memory |
| C++ | Adds a layer above it: a non-trivially-copyable type is passed by invisible reference whatever its size, because the ABI may not copy it with a memcpy. | Type systems |
| Rust | Leaves its own layout and convention deliberately unspecified, and offers repr(C) as
the explicit opt-in to the rules on this page. |
Ownership and borrowing |
| Go | Passed every argument on the stack until version 1.17, then adopted a register-based convention — a rare case of a mature language changing this decision and publishing the speedup. | Tracing collection |
| Swift | Made its convention part of the platform contract at version 5, which is what “ABI
stability” names; @frozen is the source-level promise that a type's layout will not
change. |
Version history, reference |
| Zig | Chooses per type rather than per declaration, and exposes callconv so the choice can
be written down where it matters. |
Manual memory |
Sources: each language's reference documentation and ABI notes; the Go change is described in that project's register ABI proposal.
There is a historical thread here too. Fortran passed everything by reference, which is why a Fortran routine could modify a literal and why its calling convention is still a compatibility hazard. ALGOL 60 specified call-by-name, in which an argument is re-evaluated at every use — elegant, and expensive enough that no mainstream language kept it. C's decision to copy exactly once, always, is a narrowing of that older design space, and the tables above are the price of the narrowing being paid in registers.
Reproducing this page¶
Both programs ship with the site. Neither needs a third-party package, and the classification probe needs no Windows SDK because it includes no headers.
clang -O2 -S -masm=intel --target=x86_64-pc-linux-gnu -o sysv.s classify.c
clang -O2 -S -masm=intel --target=x86_64-pc-windows-msvc -o win.s classify.c
cc -O2 -o cost cost.c && ./cost
data/abi/classify.c— every location claim in the tables above, as one freestanding translation unit.data/abi/cost.c— the timing harness, with the three guards against measuring an optimised-away call.
Timings are specific to one machine and one compiler and will differ on yours. The register assignments will not: those are fixed by the ABI documents, and the probe exists so the claim can be checked rather than believed.
Sources¶
- System V Application Binary Interface, AMD64 Architecture Processor Supplement, section 3.2.3 — gitlab.com/x86-psABIs/x86-64-ABI
- Microsoft, “x64 calling convention” — learn.microsoft.com
- ISO/IEC 9899 working draft N3096, sections 6.5.2.2 and 6.9.1 — open-std.org
- Go register-based calling convention proposal — go.dev/s/regabi
- Swift ABI stability — swift.org
- Rust reference, type layout and
repr(C)— doc.rust-lang.org