# The performance traps in generated C

> Source: https://learn-c.net/review/performance/
> Part of Learn C, free to read.

Generated C is usually written for clarity, which is the right default and occasionally the wrong one. The performance problems that survive review are not the obvious ones — the compiler handles those — they are structural: how memory is laid out, how often it is allocated, and how many times it is copied.

:::note Measure before any of this
`perf stat` takes ten seconds and tells you which half of this page applies. A high cache-miss rate points at layout; high instruction counts with good IPC point at algorithm; neither points at the loop you were about to hand-optimise.

```bash
perf stat -e cycles,instructions,cache-misses,cache-references,branch-misses ./app
perf record -g ./app && perf report
```
:::

## Allocation

### 1. malloc inside a hot loop

The most common real performance bug in generated C, and the one that scales worst.

```c
for (size_t i = 0; i < n; i++) {
    char *buf = malloc(256);            /* n allocations */
    format_row(buf, 256, &rows[i]);
    emit(buf);
    free(buf);
}
```

Correct: hoist it. One allocation, or none.

```c
char buf[256];                          /* stack — no allocator involved at all */
for (size_t i = 0; i < n; i++) {
    format_row(buf, sizeof buf, &rows[i]);
    emit(buf);
}
```

**Look for:** any `malloc`, `calloc`, `strdup` or `realloc` inside a loop body. Ask whether the buffer can be allocated once outside, or live on the stack.

### 2. realloc growth one element at a time

```c
for (size_t i = 0; i < n; i++) {
    arr = realloc(arr, (i + 1) * sizeof *arr);   /* quadratic copying */
    arr[i] = compute(i);
}
```

Each `realloc` may copy the whole array. Growing geometrically makes it amortised constant:

```c
if (len == cap) {
    size_t new_cap = cap ? cap * 2 : 16;
    void *p = realloc(arr, new_cap * sizeof *arr);
    if (!p) { free(arr); return -ENOMEM; }
    arr = p; cap = new_cap;
}
arr[len++] = compute(i);
```

Better still, when you know the count: `malloc(n * sizeof *arr)` once. And note the temporary — `arr = realloc(arr, …)` leaks the original on failure, which is [failure mode 7](/review/failure-modes/).

### 3. An arena instead of a thousand small frees

For parse trees, request handling, or anything with a natural lifetime, a bump allocator with one bulk free is dramatically faster than per-node `malloc`/`free`, and it removes a whole class of leak.

```c
typedef struct { char *base; size_t cap, used; } arena_t;

static void *arena_alloc(arena_t *a, size_t n, size_t align) {
    size_t off = (a->used + align - 1) & ~(align - 1);
    if (off + n > a->cap) return NULL;
    a->used = off + n;
    return a->base + off;
}
/* teardown is: a->used = 0;  or one free of a->base. */
```

:::warn Tell your tooling about it
AddressSanitizer cannot see inside a custom allocator, so use-after-free within an arena becomes invisible. If you introduce one, say so in `AGENTS.md` as a landmine and use ASan's manual poisoning hooks — otherwise you have traded a performance win for a hole in your safety net.
:::

## Memory layout

### 4. Array of structs where struct of arrays was wanted

```c
typedef struct { float x, y, z; char name[64]; int flags; } particle_t;
particle_t parts[100000];

for (size_t i = 0; i < n; i++) parts[i].x += parts[i].vx * dt;
```

Each iteration pulls an entire ~80-byte struct into cache to touch four bytes. You are using a small fraction of every cache line you fetch.

```c
typedef struct {                        /* struct of arrays */
    float *x, *y, *z, *vx, *vy, *vz;
    char (*name)[64];
} particles_t;

for (size_t i = 0; i < n; i++) p.x[i] += p.vx[i] * dt;   /* dense, prefetchable */
```

This is the layout change that produces order-of-magnitude differences in numeric code, and no compiler will make it for you. It is only worth doing where you sweep large arrays touching a few fields — for occasional access to whole records, array-of-structs is correct and clearer.

### 5. Struct padding nobody looked at

```c
struct rec { char flag; double value; char code; int id; };   /* 24 bytes */
struct rec { double value; int id; char flag, code; };        /* 16 bytes */
```

Same fields, a third less memory, a third fewer cache lines for an array of them. Order members largest-to-smallest and check:

```bash
pahole -C rec ./app          # shows holes and padding explicitly
```

### 6. Pointer chasing

A linked list walk is a dependent load chain: the processor cannot prefetch the next node until the current one arrives. For anything you iterate more than you insert into the middle of, a flat array wins by a wide margin — often 5-10x — regardless of what the complexity analysis says.

Generated C reaches for linked lists because they are the textbook structure. Ask what the actual access pattern is.

## Copies

### 7. strcpy where a pointer would do

Generated string handling copies defensively — often copying a substring out of a buffer purely to read it. `const char *` plus a length avoids the copy entirely:

```c
typedef struct { const char *p; size_t n; } str_t;    /* a view, not a copy */
```

A string-view type threaded through your parser removes most allocation from it, and is also usually clearer.

### 8. Passing large structs by value

```c
void process(config_t cfg);        /* copies the whole struct, per call */
void process(const config_t *cfg); /* one pointer */
```

Harmless for small structs, real for anything over a couple of cache lines in a frequently-called function.

### 9. Line-at-a-time I/O on a large file

`fgetc` in a loop, or unbuffered `read` of small chunks. Use a large buffered read, or `mmap` for a file you will scan repeatedly. Generated file handling defaults to the simplest correct thing, which is not the fastest.

## Micro-optimisation that is not

Modern compilers at `-O2` already do these. Generated code sometimes includes them, and they cost readability for nothing:

| Generated "optimisation" | Reality |
|---|---|
| `register` keyword | ignored by every modern compiler |
| `x >> 1` instead of `x / 2` | the compiler does this, and gets signedness right |
| manual loop unrolling | the compiler unrolls; yours blocks vectorisation |
| `inline` on everything | a hint; the compiler decides better than you |
| `x * 0.5` instead of `x / 2.0` | the compiler does this |
| a hand-rolled `strlen` | the libc one uses SIMD and is faster |

What actually helps and is worth asking for: `restrict` on non-aliasing pointer parameters (it unlocks vectorisation the compiler cannot otherwise prove is safe), `const` where it is true, and `static` on functions with no external linkage.

```bash
gcc -O2 -fopt-info-vec-missed -c hot.c    # why did this loop not vectorise?
```

## Threading

### 10. False sharing

Two threads writing to adjacent counters in the same cache line will fight over it, and the code looks perfectly parallel.

```c
struct { long count; } counters[NTHREADS];              /* all in one cache line */
struct { long count; char pad[64 - sizeof(long)]; }     /* one line each */
     counters[NTHREADS];
```

Generated parallel code produces the first version routinely, and the symptom is parallel code that is slower than serial. `perf c2c` finds it.

### 11. A lock around something atomic

`pthread_mutex` around a counter increment where `atomic_fetch_add` would do. Orders of magnitude cheaper under contention.

## What to do, in order

```text
1. perf stat        -> is it cache, branches, or instruction count?
2. perf record      -> which function?
3. Is that function allocating, copying, or chasing pointers in a loop?
4. Fix the layout or the allocation. Re-measure.
5. Only then consider the kernel itself.
```

:::verdict The reviewer's shortcut
Scan the diff for loops. For each one ask: **does this allocate, copy, or dereference a pointer it has to wait for?** Those three questions catch nearly everything on this page, and they are answerable by reading rather than by profiling.
:::

If you are running model inference in C, the cost model is different again — bandwidth-bound rather than compute-bound, with its own set of traps. That is covered in [local inference economics](/ai/tokenomics/).

## Common questions

### Should I ask the agent to optimise the code?

Not upfront. Ask for correct and clear, profile, then optimise the one thing that showed up. Generated "optimised" C tends to be the micro-optimisation table above — less readable for no measurable gain — while missing the layout and allocation issues that actually matter.

### Is `-O3` worth it over `-O2`?

Sometimes, and it needs measuring rather than assuming — `-O3` mostly enables more aggressive inlining and vectorisation, which can hurt through code bloat and instruction-cache pressure. Never use `-Ofast` unless you have deliberately decided to give up IEEE floating-point guarantees.

### Do sanitizers change the performance picture?

Yes, substantially — ASan is roughly 2x and distorts memory behaviour, so never benchmark a sanitized build. Develop with sanitizers on, profile with a separate `-O2` build, and keep both in your Makefile so nobody has to remember.
