Incrementing a pass-by-reference value in a called routine on a hot path: 3 methods
- Tags:
- optimization
- performance
- optimisation
- benchmarking
- hot-path
- typed-pointer
- INTEGER_32_REF
- C-inline
- micro-optimisation
Introduction
On a genuine hot path — a tokenizer inner loop, a byte classifier, a tight scan — even a single integer increment is not free, and its cost is not constant. When the integer you must update lives in a caller's variable, reached through a pass-by-reference argument, the way you read it and write it back decides how many machine instructions each update costs. This short piece benchmarks three ways to increment a 32-bit integer through such an argument in Eiffel: an inline C compound-assignment, a typed-pointer read-and-copy, and a boxed reference object. All three perform the same read-modify-write, so the comparison is like-for-like.
The three methods
All three live in a single routine, do_method, selected by an
inspect and repeated 500,000 times per pass:
do_method (id: INTEGER; integer_ptr: TYPED_POINTER [INTEGER]; value: EL_INTEGER_32_REF)
local
i: INTEGER
do
from until i > Repetition_count loop
inspect id
when 1 then
c_add_to_integer_32 (10, integer_ptr)
when 2 then
add_to_integer_32 (10, integer_ptr)
when 3 then
value.add (10)
else
end
i := i + 1
end
end
Method 1 is an inline C compound-assignment. The compiler emits the C directly into the caller, so the increment becomes a single read-modify-write against memory — no Eiffel routine-call overhead at all:
c_add_to_integer_32 (value: INTEGER; integer_ptr: TYPED_POINTER [INTEGER])
external
"C inline use <eif_eiffel.h>"
alias
"*$integer_ptr += $value;"
end
Method 2 goes through EL_TYPED_POINTER_ROUTINES_I. To increment
it must first read the current value, then copy the sum back — and both the
read and the write are general, byte-count–driven copies via
memory_copy:
add_to_integer_32 (a_value: INTEGER; integer_ptr: TYPED_POINTER [INTEGER])
local
value: INTEGER
do
value := read_integer_32 (integer_ptr) + a_value
integer_ptr.memory_copy ($value, {PLATFORM}.Integer_32_bytes)
end
The read side is itself a memory_copy, so one increment costs two
general copies:
read_integer_32 (integer_ptr: TYPED_POINTER [INTEGER]): INTEGER
do
($Result).memory_copy (integer_ptr, {PLATFORM}.Integer_32_bytes)
end
Method 3 updates EL_INTEGER_32_REF, an INTEGER_32_REF
subclass that adds an add routine. The read and write are direct
field access on the boxed object — no byte-count copy:
add (value: INTEGER)
do
set_item (item + value)
end
called on the hot path simply as value.add (10).
The harness
DEVELOPER_COMPARISON inherits EL_BENCHMARK_COMPARISON and hands
each method to compare as an agent, timing how many passes complete
inside a fixed window:
local
value: INTEGER; l_value: EL_INTEGER_32_REF
do
create l_value
compare ("perform benchmark", <<
["method 1", agent do_method (1, $value, l_value)],
["method 2", agent do_method (2, $value, l_value)],
["method 3", agent do_method (3, $value, l_value)]
>>)
Results
Passes completed in a 500 ms window, more is faster, method 1 as baseline, listed fastest first:
| Method | Mechanism | Passes / 500 ms | Relative |
|---|---|---|---|
| 1 | C inline compound-assignment (+=) | 536 | 100% (baseline) |
| 3 | EL_INTEGER_32_REF.add (field access) | 52 | −90.3% |
| 2 | TYPED_POINTER read + copy-back (two memory_copy) | 45 | −91.6% |
Note the ordering: the boxed reference object (method 3) beats the typed pointer (method 2), the reverse of what "closest to the metal wins" would predict.
Why the spread
Because all three now perform the same read-modify-write, the gaps are pure mechanism cost.
Method 1 is a single inlined compound-assignment. It compiles to a load–add–store — often one add-to-memory instruction — with no feature call and no general copy. It runs roughly ten times faster than either Eiffel alternative.
Method 2 is the slowest, and the reason is the read. Incrementing through
memory_copy means one general, byte-count–driven copy to fetch the
value (read_integer_32) and a second to store the sum
(add_to_integer_32) — two runtime copies per iteration, each reached
through a feature call. memory_copy is built for bulk transfers, not
for a scalar read-modify-write in a tight loop, and doing it twice is what sinks
it to −91.6%.
Method 3 reads and writes with item and set_item —
direct field access through a single object reference. One indirection, a field
load, an add, a field store. That is cheaper than two memory_copy
calls, so the heaviest-looking abstraction actually edges ahead of the typed
pointer for this operation.
The result overturns a tempting intuition: TYPED_POINTER.memory_copy is
not "close to the metal." It is a general runtime routine, and needing it twice
to increment a scalar costs more than plain field access on a boxed object.
Takeaway
- If you own the memory and care about speed, an inline C compound-assignment wins outright — about ten times faster here.
- Between the two pure-Eiffel options, a boxed reference with direct field access beats routing a read-modify-write through
TYPED_POINTER.memory_copytwice. - Reserve
memory_copyfor bulk or one-shot transfers, not scalar updates in a hot loop. - Measure on your own toolchain and compilation mode: these numbers depend on the compiler and on whether the code is finalized or running in workbench mode.
Notes
- Tested on my old Eiffel Studio 16.05/gcc compiler setup in finalized executable. I have not yet had time to migrate my benchmarking tools for v25.12 on my new machine.
- Naturally the routines in EL_TYPED_POINTER_ROUTINES_I have been updated to use the C inline pointer store/read. The benchmarks were performed using the original version.
