Performance Trivia : Which one's faster

Archived slides Presented by James Mitchell

List vs vector

Which snippet is faster when summing 100,000 integers?

A: List iteration
int sumElements(LinkedList<int> * list) {
  int total = 0;
  for ( Node * node = list->head; node != nullptr; node = node->next )
    total += node->value;
  return total;
}
B: Vector iteration
int sumElements(Array<int> * array) {
  int total = 0;
  int size = array->size;
  for ( int i = 0; i < size; ++i )
    total += array->items[i];
  return total;
}

Memory comes in many different speeds

The hierarchy has changed slowly; distance and bandwidth are the practical difference.
CORE L1 cache ~4 cycles
CORE L2 cache ~12 cycles
CORE L3 cache ~40 cycles
CORE DRAM ~100-300 cycles
cold linked list: next data is in main memory
CORE
node.next { value: N, next: address }
DRAM
no prefetch can know the next address until this response arrives; each hop waits about 200 cycles for DRAM
cold array: requests queue for DRAM
CORE
DRAM
requests stream out, but responses wait until each request reaches DRAM; the round trip is about 200 cycles
hot linked list: next data is in L1
CORE
node.next { value: N, next: address }
L1 CACHE
the dependency remains, but the response is nearby in about 4 L1 cycles
hot array: prefetch gets ahead
CORE
array[n]array[n]array[n]array[n]array[n]array[n]array[n]array[n]
[ 12, 7, 19, 4 ][ 23, 11, 31, 8 ][ 5, 18, 2, 27 ][ 14, 6, 29, 3 ][ 9, 21, 16, 30 ][ 2, 26, 13, 35 ]
L1 CACHE
hardware prefetch sends cache lines before the loop asks; L1 is about 4 cycles away

Array-of-structs vs Struct-of-arrays

Which snippet is faster when summing only the x coordinate? Assume there is 1,000,000 particles.

A: Array of Structs (AoS)
struct Particle {
  float x, y, z;
  int unused[5]; // Pad so sizeof(Particle) is 32 bytes
};

float sumX(const vector<Particle>& particles) {
  float total = 0.0f;
  for (const Particle& particle : particles)
    total += particle.x;
  return total;
}
B: Struct of Arrays (SoA)
struct ParticlesSoA {
  vector<float> x, y, z;
};

float sumX(const ParticlesSoA& particles) {
  float total = 0.0f;
  for (float value : particles.x)
    total += value;
  return total;
}
Array of Structures (AoS) cacheline
x y z unused
x y z unused
Struct of Arrays (SoA) cacheline
x x x x x x x x
x x x x x x x x
How much data can the CPU keep moving?
Items available or transferred for the current loop
Budget AoS: 32 B item SoA: 4 B item
Fit in one cache line (64 B) 2 items 16 items
Fit in L1 cache (32 KiB) 1,024 items 8,192 items
Fit in L2 cache (1 MiB) 32,768 items 262,144 items
RAM in 1 µs (50 GB/s) 1,562 items 12,500 items

Ring-buffer wraparound: modulo vs bitwise and

Which snippet is faster when writing 100,000 samples into a circular buffer whose capacity is guaranteed to be a power of two?

A: Bit-mask wraparound
void writeSamples(const vector<int>& samples,
        vector<int>& ring, int& writeIndex) {
  const int mask = ring.size() - 1;
  for (int sample : samples) {
    ring[writeIndex & mask] = sample;
    ++writeIndex;
  }
}
B: Modulo wraparound
void writeSamples(const vector<int>& samples,
        vector<int>& ring, int& writeIndex) {
  const int capacity = ring.size();
  for (int sample : samples) {
    ring[writeIndex % capacity] = sample;
    ++writeIndex;
  }
}

Expensive Latency and throughput

Golden Cove / Alder Lake P-core ยท uops.info latency lower is better how long one operation takes before its result is ready throughput lower is better how many can begin in a period, including independent operations in parallel
integer add 1 cycles 0.20 cycles / op
bitwise AND 1 cycles 0.20 cycles / op
float add 2 cycles 0.50 cycles / op
integer multiply 3 cycles 1 cycles / op
float multiply 4 cycles 0.5 cycles / op
integer divide 14-18 cycles 3-10 cycles / op
float divide 10-14 cycles 4-5 cycles / op
sqrt 12-19 cycles 1-3 cycles / op

Conditional write: Two control-flow styles

Which snippet is faster on random input when there are 100,000 items and a 50% chance of being above the threshold?

A: Conditional write
int filterAbove(const vector<int>& data,
            vector<int>& output, int threshold) {
  int count = 0;
  for (int value : data) {
    if (value >= threshold)
      output[count++] = value;
  }
  return count;
}
B: Branchless write
int filterAbove(const vector<int>& data,
            vector<int>& output, int threshold) {
  int count = 0;
  for (int value : data) {
    output[count] = value;
    count += (value >= threshold);
  }
  return count;
}

A wrong branch guess throws work away

The CPU starts down one path, then must recover when the guess is wrong

if (sqrt( a ) < 10)
    funcA();
else
    funcB();
correctly predicted true
sqrt(a)
sqrt( a )
funcA()
funcA()
mispredicted true
sqrt(a)
sqrt( a )
funcA()
funcA() stop / flush
funcB()
resteering funcB()
  • Predictable branches are usually cheap
  • Unpredictable branches create discarded speculative work
  • The amount of discarded work affects the cost

UTF-8 code point counting: Nibble table vs Range checks

Which snippet is faster for mostly ASCII input?

A: Nibble lookup table
int countCodePoints(const string& text) {
  const int sizes[16] = {
    1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 2, 2, 3, 4
  };
  int index = 0;
  int count = 0;
  while (index < text.size()) {
    const unsigned char byte = text[index];
    index += sizes[byte >> 4];
    ++count;
  }
  return count;
}
B: Range checks
int countCodePoints(const string& text) {
  int index = 0;
  int count = 0;
  while (index < text.size()) {
    const unsigned char byte = text[index];
    if (byte < 0x80) index += 1;
    else if (byte < 0xE0) index += 2;
    else if (byte < 0xF0) index += 3;
    else index += 4;
    ++count;
  }
  return count;
}

Branch-free is a trade-off, not a magic wand

Removing a branch can replace misprediction risk with a dependency chain

branch-free dependency chain
byte = buffer[index];
index += sizes[byte << 4];
byte = buffer[index];
  execute
byte << 4
  execute
sizes[...]
  execute
index += ...
  execute
iteration 2
byte = buffer[index];
  execute
byte << 4
  execute
sizes[...]
  execute
each step depends on the last step, it is one long dependency chain
predictable branch + index increment
byte = buffer[index];
if (byte < 0x80)
    ++index;
byte = buffer[index]
  execute
if byte < 0x80; jump
  execute
++index // (predicted)
  execute
iteration 2
byte = buffer[index]
  execute
if byte < 0x80; jump
  execute
++index
  execute
multiple iterations can be in flight before the first check finishes

Single vs split accumulator

Which snippet is faster for summing a large array of floating-point values?

A: Single accumulator
double sumArray(const vector<double>& data) {
  double sum = 0.0;
  for (double value : data)
    sum += value;
  return sum;
}
B: Split accumulators
double sumArray(const vector<double>& data) {
  double sum0 = 0.0, sum1 = 0.0;
  double sum2 = 0.0, sum3 = 0.0;
  int index = 0;
  for (; index + 3 < data.size(); index += 4) {
    sum0 += data[index];
    sum1 += data[index + 1];
    sum2 += data[index + 2];
    sum3 += data[index + 3];
  }
  for (; index < data.size(); ++index)
    sum0 += data[index];
  return sum0 + sum1 + sum2 + sum3;
}

Floating-point rules limit reordering

Multiple accumulators shorten dependency chains and can expose vectorization

Floating point math is not associative
(a + b) + c ≠ a + (b + c)

381629475281639472581463 sum =

Packed vs padded counters

Which counter layout is faster when two threads update separate counters?

A: Packed counters
struct Counters {
  atomic<int> counterA{0};
  atomic<int> counterB{0};
};

++counterA; // Thread A
++counterB; // Thread B
B: Cache-line-padded counters
struct PaddedCounter {
  atomic<int> value{0};
  char padding[60];
};

struct Counters {
  PaddedCounter counterA;
  PaddedCounter counterB;
};

++counterA; // Thread A
++counterB; // Thread B

Atomic contention moves ownership between cores

CORE A counterA 0
cache line
counterA counterB
0 0
CORE B counterB 0
  • Contending atomics can make a cache line move between cores
  • Most CPUs have 64-byte cache lines
  • Some CPUs may also prefetch adjacent cache lines even when not strictly necessary

Harmonic series

Which implementation is faster?

A: Return expression
double harmonicSeries(int n) {
  if (n <= 0) return 0.0;
  return (1.0 / n) + harmonicSeries(n - 1);
}
B: Accumulator parameter
double harmonicSeries(int n, double total = 0.0) {
  if (n <= 0) return total;
  return harmonicSeries(n - 1, total + (1.0 / n));
}

Answer A: Return expression

The highlighted expression must remain available while the recursive call returns

double harmonicSeries(int n) {
  if (n <= 0) return 0.0;
  return (1.0 / n) + harmonicSeries(n - 1);
}
Caller preserves: stack slot
sub rsp, 40       ; frame and shadow space
mov [rsp+32], ecx ; preserve n
dec ecx
call harmonicSeries
movsd xmm1, [one]
cvtsi2sd xmm2, [rsp+32]
divsd xmm1, xmm2
addsd xmm0, xmm1  ; finish the return expression
add rsp, 40
ret
Callee preserves: non-volatile register
push rbx          ; save callee's rbx
sub rsp, 32       ; shadow space
mov ebx, ecx      ; preserve n
dec ecx
call harmonicSeries
movsd xmm1, [one]
cvtsi2sd xmm2, ebx
divsd xmm1, xmm2
addsd xmm0, xmm1  ; finish the return expression
add rsp, 32
pop rbx
ret
  • Either the caller saves the stack value; or the callee preserves the non-volatile register

Answer B: Accumulator parameter

The updated argument is ready before the recursive call

double harmonicSeries(int n, double total = 0.0) {
  if (n <= 0) return total;
  return harmonicSeries(n - 1, total + (1.0 / n));
}
Normal function call
sub rsp, 40       ; frame and shadow space
cvtsi2sd xmm2, ecx
movsd xmm3, [one]
divsd xmm3, xmm2
addsd xmm1, xmm3  ; update total
dec ecx
call harmonicSeries
add rsp, 40
ret
Tail call
cvtsi2sd xmm2, ecx
movsd xmm3, [one]
divsd xmm3, xmm2
addsd xmm1, xmm3  ; update total
dec ecx
jmp harmonicSeries ; no work remains
  • The sum argument is computed before the recursive call
  • A normal call returns to the current frame; a tail call can jump directly to the next invocation
  • Tail-call generation depends on the calling convention and optimizer

Summary

0110 1011 0010 1101
WAITING FOR DATA bytes are still in flight
a = b / c d = sqrt(c)
WAITING FOR AN OPERATION the required unit is occupied
discarded
RECOVERING FROM A WRONG GUESS the followed path was wrong

    Questions

    quiz.cpp-perf.com Personal side project; not affiliated with my employer.