Lesson 22-Assembly Language Performance Optimization Strategies

Introduction: Why Your Assembly Code Is Probably Slower Than You Think

Let me tell you a secret that the compiler vendors don’t want you to know: most handwritten assembly is slower than optimized C.*

I know, I know — that’s heresy in an assembly language course. Shouldn’t assembly be faster? Shouldn’t “coding closer to the metal” mean “faster code”?

Well… sometimes. But not always. And if you don’t understand why code is fast or slow, you’ll write assembly that’s slower than the compiler’s output. You’ll use instructions that seemed fast in 1995 but are slow today. You’ll ignore cache behavior and wonder why your “optimized” code is 10× slower than a naive C implementation.

In this article, we’re going to talk about performance optimization in assembly language. We’ll cover:

  1. Why modern CPUs are weird: Out-of-order execution, branch prediction, and why the “obvious” optimization is sometimes wrong.
  2. Instruction selection: Which instructions are fast, which are slow, and which are weirdly slow.
  3. Memory optimization: Cache lines, prefetching, and why your access pattern matters more than your instruction choice.
  4. SIMD optimization: Using SSE/AVX to process 4-32 values per instruction.
  5. Measuring performance: How to use performance counters and profiling tools to find your bottleneck.

This is going to be a long article. Performance optimization is complicated, and the “rules” change every CPU generation. But if you understand the principles, you can optimize for any CPU.

The Myth of “Simple = Fast”

Let’s start with a story. In 1995, a common optimization was:

; 1995: This was "fast"
XOR EAX, EAX        ; Fast way to set EAX = 0 (1 cycle, 1 byte)

Compared to:

; 1995: This was "slow"
MOV EAX, 0          ; Slow way to set EAX = 0 (1 cycle, 5 bytes)

The XOR reg, reg trick was faster because:

  1. It’s shorter (1 byte vs. 5 bytes).
  2. It breaks dependencies (the CPU knows EAX is 0 without waiting for previous instructions).

Fast-forward to 2024: Both instructions take exactly the same time on a modern CPU. Why? Because the CPU’s instruction decoder recognizes MOV reg, 0 as a special case and optimizes it internally. The decoder turns MOV EAX, 0 into the same micro-operation as XOR EAX, EAX.

The moral: “Optimization advice” from 1995 is probably wrong today. You need to understand your CPU, not the 8086.

The Three Performance Enemies

When optimizing assembly code, you’re fighting three enemies:

  1. Latency: How long does one instruction take to complete?
  2. Throughput: How many instructions can the CPU start per cycle?
  3. Memory: How often does the CPU wait for data from RAM?

Instruction Performance: Latency, Throughput, and Why Pipelining Matters

Every x86 instruction has two performance numbers that matter:

  1. Latency: How many cycles does this instruction take from start to finish?
  2. Reciprocal throughput: How many cycles between consecutive executions of this instruction?

These are different because of pipelining and out-of-order execution.

What Pipelining Does (Simplified)

Imagine a car factory. It takes 10 hours to build a car. But if you pipeline the factory, you can start a new car every 1 hour (even though each car still takes 10 hours to complete).

The CPU does the same thing:

  • Instruction fetch (get the instruction from memory/L1 cache)
  • Decode (figure out what the instruction does)
  • Rename (eliminate false dependencies)
  • Issue (send to execution unit)
  • Execute (do the actual computation)
  • Writeback (store the result)

Each stage takes ~1 cycle. So a perfectly pipelined CPU can complete 1 instruction per cycle, even though each instruction takes 5-6 cycles from start to finish.

BUT: If the next instruction depends on the previous one, the pipeline stals. The CPU can’t start the next instruction until the previous one finishes.

Example: Dependency Chain

; BAD: Dependency chain (CPU can't pipeline)
MOV EAX, [mem1]    ; Latency: 4 cycles (cache hit)
ADD EAX, [mem2]    ; Depends on EAX → stall 4 cycles
MOV [result], EAX  ; Depends on EAX → stall

The ADD can’t start until the MOV finishes (4 cycles later). The MOV [result] can’t start until the ADD finishes.

Total time: ~12 cycles.

; BETTER: Break the dependency chain
MOV EAX, [mem1]    ; Latency: 4 cycles
MOV EBX, [mem2]    ; Can start *in parallel* with previous!
ADD EAX, EBX        ; Can start after both loads finish
MOV [result], EAX

Now the CPU can pipeline the two loads. They both take 4 cycles, but they overlap.

Total time: ~8 cycles.

The Cost of Common Instructions (Alder Lake / Zen 4)

Let me give you approximate performance numbers for a modern CPU (Intel Alder Lake or AMD Zen 4):

InstructionLatencyThroughputNotes
MOV reg, reg1 cycle1/cycleRegister-register move (eliminated by register renaming)
MOV reg, [mem]4 cycles (L1)1/cycleLoad from memory (L1 cache)
MOV [mem], reg4 cycles (L1)1/cycleStore to memory (L1 cache)
ADD reg, reg1 cycle1/cycleInteger addition
SUB reg, reg1 cycle1/cycleInteger subtraction
LEA reg, [base+idx*scale]1 cycle1/cycleAddress calculation (doesn’t access memory!)
MUL reg (32-bit)3 cycles1/cycleUnsigned multiplication
IMUL reg (32-bit)3 cycles1/cycleSigned multiplication
DIV reg (32-bit)10-30 cycles1/10 cyclesDivision (SLOW!)
IDIV reg (32-bit)10-30 cycles1/10 cyclesSigned division (SLOW!)
PUSH reg1 cycle1/cycleStack push
POP reg1 cycle1/cycleStack pop
CALL label1-2 cycles1/cycleFunction call
RET1-2 cycles1/cycleFunction return
JMP label0-2 cycles1/cycleUnconditional jump
Jcc label (taken)2-20 cycles1/cycleConditional jump (if predicted correctly)
Jcc label (not taken)2-20 cycles1/cycleConditional jump (if predicted incorrectly = huge penalty)

Key takeaways:

  1. Division is glacial. 10-30 cycles! If you can avoid division, do it.
  2. Conditional jumps are fast… if predicted correctly. If the branch predictor gets it wrong, you pay a 15-20 cycle penalty.
  3. LEA is amazing. It does arithmetic (base + idxscale + offset) in 1 cycle without modifying flags*. Use it!
  4. Memory access is 4 cycles. Not “slow” (thanks to L1 cache), but not “free” either.

Memory Optimization: Cache Lines, Prefetching, and Access Patterns

If you’ve been paying attention, you noticed that memory access (L1 cache) takes 4 cycles. That’s 4× slower than a register-register ADD.

But L2 cache is ~12 cycles. L3 cache is ~40 cycles. RAM is 150-300 cycles.

The moral: The distance between your data matters. A lot.

Cache Lines and Spatial Locality

The CPU doesn’t load one byte from memory. It loads a cache line (64 bytes on x86-64).

If you access array[0], the CPU loads array[0] through array[15] (assuming 4-byte integers) into the L1 cache.

Implication: Sequential access is fast. Random access is slow.

; FAST: Sequential access (CPU prefetches ahead)
MOV ECX, 1000         ; Count
LEA ESI, [array]    ; Base address
xor EAX, EAX         ; Sum = 0

sum_loop:
    ADD EAX, [ESI]      ; Add array[i]
    ADD ESI, 4           ; Advance to array[i+1]
    LOOP sum_loop        ; Decrement ECX, jump if not zero
; SLOW: Random access (CPU can't prefetch)
MOV ECX, 1000         ; Count
XOR EAX, EAX         ; Sum = 0

random_loop:
    ; Generate "random" index (bad example, but you get the idea)
    RDTSC                 ; Read timestamp counter
    AND EAX, 0x3FF       ; Modulo 1024
    SHL EAX, 2           ; Index × 4 (int = 4 bytes)
    ADD EAX, [array + EAX] ; Random access!
    ; ...

Cache Line Aligment

If your data structure crosses a cache line boundary, the CPU needs two cache line loads to access it.

; C example: Bad alignment
struct BadAlignment {
    char pad;      // 1 byte
    int  value;    // 4 bytes → crosses cache line if `pad` isn't aligned!
};

In assembly, you can control alignment with the ALGN directive (MASM/NASM):

section .data
ALGN 64                  ; Align to 64-byte cache line boundary
my_data dd 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0

Hardware Prefetching

Modern CPUs have hardware prefetchers that guess what memory you’ll access next.

If you access:

array[0] → array[1] → array[2] → ...

The prefetcher will automatically load array[3], array[4], etc. into the L1 cache before you access them.

BUT: If your access pattern is random, the prefetcher can’t help. Worse, it might evict useful data from the cache to make room for useless prefetches.

Software Prefetching (PREFETCH Instruction)

If you know you’ll need data soon, you can hint the CPU with the PREFETCH instruction (SSE2+):

; Tell the CPU: "I'll need this cache line soon"
PREFETCHT0 [array + 1024]   ; Prefetch to L1 cache (temporal)
PREFETCHT1 [array + 1024]   ; Prefetch to L2 cache (temporal)
PREFETCHNTA [array + 1024]  ; Prefetch to L1 cache (non-temporal = don't keep it around)

When to use: If you’re processing an array and you know you’ll need array[i+16] soon, prefetch it!

Warning: PREFETCH is a hint. The CPU might ignore it. And using it incorrectly can hurt performance (by evicting useful data).

SIMD Optimization: SSE, AVX, and Processing Multiple Values per Instruction

If you’ve been paying really close attention, you noticed that the throughput of ADD is 1/cycle. That means a modern CPU can start one ADD per cycle, but each ADD only processes one value.

What if you could process four values per instruction?

Enter SIMD (Single Instruction, Multiple Data).

What Is SIMD?

SIMD lets you perform the same operation on multiple data elements simultaneously.

; WITHOUT SIMD: Add 4 integers (4 instructions)
MOV EAX, [a]          ; Load a[0]
ADD EAX, [b]          ; Add b[0]
MOV [result], EAX     ; Store result[0]
MOV EAX, [a+4]        ; Load a[1]
ADD EAX, [b+4]        ; Add b[1]
MOV [result+4], EAX    ; Store result[1]
; ... repeat for a[2], a[3]
; WITH SSE2 SIMD: Add 4 integers (1 instruction!)
MOVUPD XMM0, [a]      ; Load 4 integers (128 bits) from a[]
PADDD XMM0, [b]       ; Add 4 integers from b[] (SIMD!)
MOVUPD [result], XMM0  ; Store 4 results

Speedup: ~4× for this example. And AVX2 can process 8 integers per instruction (256-bit YMM registers)!

SSE/AVX Instruction Sets (Quick Reference)

ExtensionRegister WidthValues per InstructionIntroduction
MMX64 bits2× 32-bit OR 4× 16-bit OR 8× 8-bit1997 (Pentium MMX)
SSE128 bits4× 32-bit OR 4× 32-bit float1999 (Pentium III)
SSE2128 bits2× 64-bit OR 4× 32-bit OR 8× 16-bit2001 (Pentium 4)
SSE3128 bitsSame as SSE2 + horizontal ops2004 (Prescott)
SSSE3128 bitsSame as SSE3 + byte-shuffle2006 (Core 2)
SSE4.1/4.2128 bitsSame as SSSE3 + string ops2006 (Penryn)
AVX256 bits8× 32-bit OR 4× 64-bit2011 (Sandy Bridge)
AVX2256 bitsSame as AVX + gather/scatter2013 (Haswell)
AVX-512512 bits16× 32-bit OR 8× 64-bit2015 (Skylake-X)

When to Use SIMD

SIMD is amazing for:

  1. Image processing: Every pixel gets the same operation (brightness adjustment, blur, etc.).
  2. Audio processing: Every sample gets the same filter applied.
  3. Matrix math: Neural networks, scientific computing.
  4. String processing: memcpy, memcmp, etc.

BUT: SIMD has overhead:

  1. Loading/storing SIMD registers takes time (memory bandwidth).
  2. Type conversion (e.g., int → float) is expensive.
  3. Conditional processing (e.g., “only add if positive”) is hard with SIMD.

Example: SIMD Image Brightness Adjustment

Let’s say you want to adjust the brightness of a grayscale image by adding a constant to every pixel.

Without SIMD (scalar):

; Adjust brightness of 8-bit grayscale image (one pixel at a time)
; ESI = source image, EDI = destination, ECX = pixel count
adjust_brightness_scalar:
    MOV AL, [ESI]         ; Load pixel (1 byte)
    ADD AL, [brightness]  ; Add brightness offset
    ; (Handle overflow... this is simplified)
    MOV [EDI], AL        ; Store pixel
    INC ESI                ; Advance source
    INC EDI                ; Advance destination
    DEC ECX                ; Decrement counter
    JNZ adjust_brightness_scalar

With SSE2 SIMD (16 pixels at a time!):

; Adjust brightness using SSE2 (16 pixels per iteration!)
; ESI = source, EDI = destination, ECX = pixel count
adjust_brightness_simd:
    MOVUPD XMM0, [ESI]    ; Load 16 pixels (128 bits = 16 bytes)
    PADDB XMM0, [brightness_vec]  ; Add brightness to all 16 pixels!
    MOVUPD [EDI], XMM0  ; Store 16 pixels
    ADD ESI, 16           ; Advance source by 16 bytes
    ADD EDI, 16           ; advance destination
    SUB ECX, 16           ; 16 fewer pixels to process
    JNZ adjust_brightness_simd

Speedup: ~8-16× (depending on memory bandwidth).

Branching: Why Conditional Jumps Are Expensive (and How to Fix Them)

Let me tell you about the branch predictor. It’s the most important part of a modern CPU that you’ve never heard of.

The Problem with Conditional Jumps

CMP EAX, 0
JNE not_zero          ; Conditional jump
; ...
not_zero:
; ...

The CPU doesn’t know whether the jump will be taken until the CMP finishes (1 cycle later). But the CPU can’t wait — it needs to fetch the next instruction now.

So it guesses (predicts) whether the jump will be taken. If it’s right, the pipeline keeps going. If it’s wrong, the CPU has to flush the pipeline and start over (15-20 cycle penalty!).

Branch Prediction 101

Modern CPUs use two-level adaptive branch prediction:

  1. Pattern history table: Records whether each branch was taken or not taken the last N times.
  2. Pattern recognition: If the branch pattern is taken, not taken, taken, not taken (alternating), the predictor learns this.

Accuracy: ~95% for most code. But pathological patterns (e.g., random) can confuse the predictor.

How to Help the Branch Predictor

1. Make Branches Predictable

; GOOD: Predictable branch (always taken OR always not taken)
MOV ECX, 1000
loop:
    CMP [array + ECX*4], 0
    JE skip           ; Branch NOT taken (array[i] != 0 most of the time?)
    ; ... do work ...
skip:
    LOOP loop
; BAD: Unpredictable branch (random)
MOV ECX, 1000
loop:
    RDTSC                 ; Get "random" value
    AND EAX, 1           ; EAX = 0 or 1
    CMP EAX, 0
    JE taken               ; 50% chance → branch predictor struggles!
    ; ...
taken:
    ; ...

2. Use Conditional Moves (CMOVcc) Instead of Branches

If the branch is unpredictable, consider using conditional moves (Pentium Pro+):

; Instead of:
CMP EAX, EBX
JL  less
MOV EAX, EBX
JMP done
less:
    ; EAX stays the same
done:

; Use conditional move:
CMP EAX, EBX
CMOVGE EAX, EBX   ; If EAX >= EBX, EAX = EBX (no branch!)

Why this helps: CMOV doesn’t have a branch prediction penalty. It’s just a 1-cycle instruction (if the condition is false, it’s a 1-cycle NOP).

BUT: CMOV has higher latency than a correctly predicted branch. Use it only for unpredictable branches.

3. Align Branch Targets

The CPU fetches instructions in 16-byte chunks. If a branch target is misaligned (crosses a 16-byte boundary), the CPU might need two fetches.

; GOOD: Aligned branch target
ALGN 16
hot_path:
    ; ...
; BAD: Misaligned branch target
    DB 0x90            ; NOP (1 byte)
hot_path:                ; Now at offset 1 (misaligned!)
    ; ...

Loop Optimization: Unrolling, Peeling, and Why LOOP Is Slow

Let’s talk about loops. They’re the most common construct in programming, and they’re easy to optimize poorly.

The Problem with LOOP

; The `LOOP` instruction (decrement ECX, jump if not zero)
MOV ECX, 1000
loop_start:
    ; Do work
    LOOP loop_start      ; Decrement ECX, jump if ECX != 0

Why LOOP is slow:

  1. It’s a compound instruction (decrement + compare + jump).
  2. It has high latency on some CPUs (because of the flag update).
  3. It creates a dependency chain (each LOOP depends on the previous one).

Faster alternative:

; Use explicit CMP + Jcc (often faster)
MOV ECX, 1000
loop_start:
    ; Do work
    DEC ECX                ; Decrement (doesn't affect CF!)
    JNZ loop_start       ; Jump if ECX != 0

Loop Unrolling

If the loop body is short, the loop overhead (decrement + branch) can be a significant fraction of the total time.

Solution: Unroll the loop (process multiple iterations per loop body):

; BEFORE: Loop with overhead
MOV ECX, 1000
loop_scalar:
    ADD EAX, [ESI + ECX*4]
    LOOP loop_scalar
; AFTER: Unrolled loop (4 iterations per body)
MOV ECX, 1000 / 4     ; 250 iterations
loop_unrolled:
    ADD EAX, [ESI + ECX*16 + 0]   ; Iteration i
    ADD EAX, [ESI + ECX*16 + 4]   ; Iteration i+1
    ADD EAX, [ESI + ECX*16 + 8]   ; Iteration i+2
    ADD EAX, [ESI + ECX*16 + 12]  ; Iteration i+3
    LOOP loop_unrolled

Speedup: ~1.5-2× (depending on loop body size).

BUT: Unrolling increases code size. If the loop body is large, unrolling might hurt (by causing more instruction cache misses).

Loop Peeling

If the loop has special cases for the first/last iteration, peel them:

; BEFORE: Loop with special case inside
MOV ECX, 1000
loop_with_check:
    CMP ECX, 1000         ; First iteration?
    JNE not_first
    ; Do special first-iteration setup
not_first:
    ; Do work
    LOOP loop_with_check
; AFTER: Peel the first iteration
; Do special first-iteration setup (outside loop)
MOV ECX, 999          ; 999 iterations remaining
loop_peeled:
    ; Do work (no special case!)
    LOOP loop_peeled

Why this helps: Eliminates the branch inside the loop. The branch predictor might predict it correctly, but why take the chance?

Function Call Optimization: inline, static, and Why __fastcall Helps

Function calls have overhead:

  1. CALL instruction: 1-2 cycles (fetch + push return address).
  2. Stack frame setup: PUSH EBP; MOV EBP, ESP; SUB ESP, N (3-5 cycles).
  3. Stack frame teardown: MOV ESP, EBP; POP EBP; RET (3-5 cycles).
  4. Parameter passing: Pushing arguments (N cycles).

Total overhead: ~10-20 cycles per function call.

If the function body is short (e.g., a getter/setter), the overhead can be larger than the actual work!

Solution 1: Inline the Function

If the function is small, consider inlining (copy the function body directly into the caller):

; BEFORE: Function call overhead
MOV EAX, [this + 0]    ; Call getter
CALL get_value          ; Overhead!
; Use EAX...

; AFTER: Inlined function (no CALL overhead!)
MOV EAX, [this + 0]    ; get_value() is just: MOV EAX, [this + 0]
; Use EAX...

In C, you’d use __attribute__((always_inline)) (gcc) or __forceinline (MSVC). In assembly, you just… copy-paste the code.

BUT: Inlining increases code size. If the function is large, inlining every call site will blow up your code size (causing more instruction cache misses).

Solution 2: Use __fastcall (Register Parameters)

The __fastcall convention passes the first two arguments in ECX and EDX (instead of on the stack):

; BEFORE: cdecl (arguments on stack)
PUSH [b]                ; Push argument 2
PUSH [a]                ; Push argument 1
CALL add                  ; Call function
ADD ESP, 8              ; Clean up stack
; ...

add:
    MOV EAX, [ESP + 4]    ; Load a (from stack)
    ADD EAX, [ESP + 8]    ; Add b (from stack)
    RET
; AFTER: __fastcall (arguments in registers)
MOV ECX, [a]            ; Argument 1 in ECX
MOV EDX, [b]            ; Argument 2 in EDX
CALL add_fastcall        ; Call function (no stack cleanup!)
; ...

add_fastcall:
    MOV EAX, ECX         ; a is already in ECX
    ADD EAX, EDX         ; Add b (in EDX)
    RET

Speedup: ~1.2-1.5× for small functions with few arguments.

Solution 3: Use __attribute__((hot)) (gcc) or __declspec(code_seg(".text.hot")) (MSVC)

Tell the compiler which functions are performance-critical. It’ll:

  1. Optimize them more aggressively.
  2. Place them in the same code section (improving instruction cache locality).

In assembly, you can control section placement with:

section .text.hot           ; Place in ".text.hot" section
global critical_function
critical_function:
    ; ...

Measuring Performance: Profiling and Performance Counters

If you’ve been following along, you might be thinking: “This is all theoretical. How do I know if my optimization actually helped?”

Great question. Let’s talk about measuring performance.

The Naive Approach: RDTSC

The simplest way to measure time is the RDTSC (Read Timestamp Counter) instruction:

; Measure performance using RDTSC
global benchmark
benchmark:
    RDTSC                    ; EDX:EAX = timestamp
    MOV [start_lo], EAX
    MOV [start_hi], EDX
    
    ; === DO WORK HERE ===
    CALL function_to_benchmark
    
    RDTSC                    ; EDX:EAX = timestamp
    MOV [end_lo], EAX
    MOV [end_hi], EDX
    
    ; Compute elapsed time (end - start)
    ; ...

Problems with RDTSC:

  1. It measures wall-clock time, not CPU time. If the OS scheduler interrupts your code, you’ll measure the interrupt handlers too!
  2. It’s not precise for short functions (< 1000 cycles).
  3. It doesn’t count cache misses, branch mispredictions, etc.

The Right Approach: Performance Counters

Modern CPUs have hardware performance counters that count exactly what you care about:

  1. Cache misses (L1, L2, L3)
  2. Branch mispredictions
  3. Instructions retired (not just started)
  4. Cycle count (CPU cycles, not wall-clock time)

Using perf (Linux)

# Measure cache misses
perf stat -e cache-misses ./my_program

# Measure branch mispredictions
perf stat -e branch-misses ./my_program

# Measure instructions retired vs. cycles
perf stat -e instructions,cycles ./my_program

Using VTune (Intel) or uProf (AMD)

These are GUI profilers that show you:

  1. Which functions are slowest.
  2. Which loops have the most cache misses.
  3. Which branches are mispredicted.

Profiling Workflow

  1. Compile with debug symbols (-g in gcc/CLang).
  2. Run the profiler (perf record, vtune collect).
  3. Analyze the results (perf report, vtune report).
  4. Find the bottleneck (usually 1-2 functions/loops).
  5. Optimize the bottleneck.
  6. Goto step 2 (did it help?)

The 80/20 rule: 80% of the runtime is spent in 20% of the code. Find that 20% and optimize it.

Common Optimization Mistakes (and How to Avoid Them)

Let’s end with a checklist of mistakes that even experienced assembly programmers make.

Mistake #1: Optimizing the Wrong Thing

The mistake: Spending 3 days optimizing a function that accounts for 0.1% of runtime.

The fix: Profile first. Find the bottleneck. Optimize that.

Mistake #2: Ignoring Cache Behavior

The mistake: Writing a “clever” data structure that has terrible cache behavior.

; BAD: Linked list traversal (cache-unfriendly)
MOV ESI, [head]
loop_linked_list:
    ; Do work on current node
    MOV ESI, [ESI + next]  ; Follow pointer (cache miss!)
    CMP ESI, 0
    JNE loop_linked_list
; GOOD: Array traversal (cache-friendly)
MOV ECX, [count]
LEA ESI, [array]
loop_array:
    ; Do work on array[i]
    ADD ESI, 16              ; Advance (prefetcher helps!)
    DEC ECX
    JNZ loop_array

The fix: Use arrays, not linked lists. If you must use a linked list, use a pool allocator (allocate all nodes in one array).

Mistake #3: Over-Optimizing for an Old CPU

The mistake: Using XOR EAX, EAX instead of MOV EAX, 0 because “it’s faster” (on a 1995 Pentium).

The fix: Test on your CPU. What’s fast on Intel might be slow on AMD. What’s fast on Alder Lake might be slow on Zen 4.

Mistake #4: Using SIMD Everywhere

The mistake: Rewriting everything in AVX-512 because “SIMD = fast”.

The fix: SIMD helps for data-parallel problems (image processing, matrix math). It doesn’t help for control-heavy code (parsing JSON, compiling code).

Mistake #5: Ignoring the Compiler

The mistake: Assuming your handwritten assembly is faster than the compiler’s output.

The fix: Compare your assembly to the compiler’s output (gcc -S -O3). Modern compilers are really good. Sometimes they’ll generate better code than you.

When to use assembly:

  1. Compiler can’t vectorize (e.g., complex loop with dependencies).
  2. You need exact control over instruction selection (e.g., crypto code that needs constant-time execution).
  3. *You’re optimizing a hot loop that the compiler can’t* optimize (rare!).
Share your love