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:

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.

Back to top

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.

Fieldsoffset and width of eachgroupEightbytesfield offset div 8mergeClassjoin of the fields in eachassignLocationnext free register, else stack
Figure 1. The System V classification of one aggregate, as four stages. Only the third stage looks at types; the first two are arithmetic on offsets, and the last is bookkeeping over the registers already spent. A type wider than two eightbytes is rejected at stage two and never reaches the merge. Source: System V AMD64 psABI, section 3.2.3.

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.

MergeNO_CLASSSSEINTEGERMEMORY
NO_CLASSNO_CLASSSSEINTEGERMEMORY
SSESSESSEINTEGERMEMORY
INTEGERINTEGERINTEGERINTEGERMEMORY
MEMORYMEMORYMEMORYMEMORYMEMORY

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.

MEMORYabsorbing: forces the whole aggregate onto the stackINTEGERone of rdi, rsi, rdx, rcx, r8, r9SSEone of xmm0 through xmm7NO_CLASSidentity: merging it changes nothing
Figure 2. The four classes as a chain, greatest first. Merging two classes within one eightbyte takes the higher of the two, which makes the merge associative, commutative, and idempotent — so the class of an eightbyte does not depend on the order its fields are visited. 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.

Back to top

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.

System Vany type of 16 bytes or lessMicrosoft x64sizeof in {1, 2, 4, 8}Both1, 2, 4, 8 bytes
Figure 3. Which struct types arrive in a register, by ABI. The overlap is exactly the sizes that are a power of two and at most eight, so a 12-byte or 16-byte struct is a register pair on Linux and a pointer on Windows. The asymmetry runs one way: every struct Windows passes directly, System V also passes directly. Sources: System V AMD64 psABI §3.2.3; Microsoft x64 calling convention.

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.

Back to top

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.

16 bytes — 0.3 ns. two register moves, no copy16 bytes0.3 ns32 bytes — 6.71 ns. 2 inline 16-byte stores32 bytes6.71 ns64 bytes — 6.39 ns. 4 inline 16-byte stores64 bytes6.39 ns128 bytes — 6.98 ns. 8 inline 16-byte stores128 bytes6.98 ns192 bytes — 7.61 ns. 12 inline 16-byte stores192 bytes7.61 ns256 bytes — 8.52 ns. 16 inline 16-byte stores256 bytes8.52 ns384 bytes — 10.41 ns. rep movsq, 48 qwords384 bytes10.41 ns512 bytes — 14.99 ns. rep movsq, 64 qwords512 bytes14.99 ns768 bytes — 15.51 ns. rep movsq, 96 qwords768 bytes15.51 ns1024 bytes — 16.01 ns. rep movsq, 128 qwords1024 bytes16.01 ns016.01 ns
Figure 4. The extra time a call costs when the aggregate travels by value rather than by pointer, in nanoseconds per call. Colour names the copy strategy the compiler chose, not the size of the number: registers, inline vector stores, or 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.

Back to top

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.

Back to top

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

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

  1. System V Application Binary Interface, AMD64 Architecture Processor Supplement, section 3.2.3 — gitlab.com/x86-psABIs/x86-64-ABI
  2. Microsoft, “x64 calling convention” — learn.microsoft.com
  3. ISO/IEC 9899 working draft N3096, sections 6.5.2.2 and 6.9.1 — open-std.org
  4. Go register-based calling convention proposal — go.dev/s/regabi
  5. Swift ABI stability — swift.org
  6. Rust reference, type layout and repr(C)doc.rust-lang.org

Back to top