Enhance Chapter 12 on Memory Alignment in Arenas

- Expanded section on memory alignment, clarifying the importance of data alignment for CPU efficiency.
- Introduced detailed explanations on type alignment versus data alignment.
- Added tables summarizing type sizes and alignments for clarity.
- Included step-by-step examples demonstrating struct alignment and padding.
- Revised code examples to reflect best practices for memory allocation and alignment.
- Updated PDF pagination to reflect the new content structure.
This commit is contained in:
2026-10-13 23:46:24 -03:00
parent cfaa0ca470
commit e4a362a8fb
2 changed files with 288 additions and 13 deletions
+288 -13
View File
@@ -231,33 +231,308 @@ que lo vuelven "de producción".
### 12.4.1 Alineación de memoria
Los procesadores no leen cualquier dirección igual de rápido, y algunos **exigen** que ciertos tipos
empiecen en direcciones múltiplo de su tamaño (`int` a múltiplo de 4, `double` a múltiplo de 8, etc.).
Si repartís tamaños impares (por ejemplo 3 bytes), la próxima asignación puede quedar **desalineada**
y provocar comportamientos raros o lentitud.
#### Por qué a la CPU le importa dónde empieza un dato
La solución es **redondear el tamaño hacia arriba** hasta un múltiplo de la alineación:
Los procesadores no leen cualquier dirección igual de rápido, y algunos **exigen** que ciertos tipos
empiecen en direcciones "redondas". Si repartís tamaños impares (3 bytes, 5, 1...) el próximo dato
puede quedar **desalineado** y provocar lentitud o, en arquitecturas estrictas, un *crash*
(comportamiento indefinido).
Pero antes de seguir hay que desarmar una trampa de vocabulario, porque es la raíz de tu duda:
> **"Múltiplo de su tamaño" es un atajo que solo vale para los primitivos.** La regla de verdad es:
> cada tipo exige que su dirección sea **múltiplo de su ALINEACIÓN**, y la alineación **no siempre
> coincide con el tamaño**. Para `int`, `double` y `char` da igual; para un `struct`, casi nunca.
> Eso es exactamente lo que hace que te preguntes si podés mezclar tipos. Podés. Seguí leyendo.
#### La alineación es una propiedad del TIPO, no del dato ni de la arena
Una arena **no tiene tipo**: guarda bytes y no sabe qué vas a poner adentro. La alineación no es una
propiedad del valor ni de la arena: es una propiedad **del tipo** que vos vas a escribir en ese
hueco. El lenguaje ya sabe, para cada tipo, cuánto exige:
| Tipo | Tamaño | Alineación típica (x86-64) |
|------|:------:|:--------------------------:|
| `char` | 1 | 1 |
| `short` | 2 | 2 |
| `int` | 4 | 4 |
| `long`, `double`, puntero | 8 | 8 |
| `struct` | según sus miembros (con relleno) | **= la mayor alineación de sus miembros** |
#### La idea que te salva: UNA alineación para todos
Acá está la respuesta a tu primera pregunta. ¿Podés tener `int`, `char`, `double` y structs complejos
en la **misma** arena, bien alineados? **Sí, y no hace falta calcular nada por tipo.**
La clave: si **cada** bloque arranca en un múltiplo de 8, entonces arranca también en un múltiplo de
4, de 2 y de 1. Un múltiplo de 8 *es* múltiplo de todos los divisores de 8. Así que alineando
**todo** a 8 cubrís a la vez a `char` (1), `int` (4), `double`/puntero (8) y a cualquier struct cuya
alineación sea ≤ 8:
```text
base+0 base+8 base+16 base+24
| | | |
v v v v
[ double ][ int ][ char ][ struct ... ]
^8 ^8 ^8 ^8
todos múltiplos de 8 -> 8, 4, 2 y 1 quedan satisfechos
```
Por eso el ejemplo de `main` en 12.3 puede pedir un `Point` y después 100 `int` del mismo bloque: no
calculás la posición de nadie, **alineás cada reparto al mismo valor** y listo.
> **¿Qué alineación universal uso?** `8` alcanza para `int`, `double` y punteros. Para estar
> cubierto ante *cualquier* tipo (incluido algún SIMD o `long double`), usá
> `ARENA_ALIGN = _Alignof(max_align_t)` (normalmente 16). Es el valor que el estándar considera
> suficiente para todo. Un poquito más de desperdicio, cero dolores de cabeza.
#### Structs: por qué el tamaño NO te dice la alineación
Esta es la parte que confunde. Mirá:
```c
#define ARENA_ALIGN 8 // alineamos a 8 bytes
struct Mixed { char tag; int value; }; // sizeof = 8 | _Alignof = 4
struct Pair { double a; double b; }; // sizeof = 16 | _Alignof = 8
struct Quad { int a, b, c, d; }; // sizeof = 16 | _Alignof = 4
```
¿Por qué `Mixed` mide 8 y no 5? Porque el compilador **inserta 3 bytes de relleno** (*padding*)
después de `tag`, para que el `int` caiga alineado a 4. Y mide 8 (múltiplo de 4) para que, en un
arreglo `Mixed items[10]`, el `value` del segundo elemento también quede alineado.
De ahí sale el dato que te ahorra todo el cálculo:
> **La alineación de un tipo SIEMPRE divide a su tamaño.** Es una garantía del estándar de C, y
> existe precisamente para que los arreglos funcionen. Consecuencia directa: si alineás el bloque a
> la alineación del struct, **todos** sus miembros caen alineados solos. Nunca tenés que ubicar
> `tag` ni `value` a mano.
Entonces, tu pregunta "¿cómo calculo, en base al `sizeof`, la posición que debe ocupar un struct
complejo?" tiene una respuesta incómoda y liberadora: **no lo calcules desde el `sizeof`.** El
`sizeof` te dice *cuánto ocupa*, no *dónde tiene que empezar*. Para saber eso, pedile la alineación
al lenguaje:
- `_Alignof(Type)` (C11) te da la alineación en bytes.
- `alignof(Type)` con `<stdalign.h>` (el alias con nombre lindo).
- `_Alignas(16)` en una variable te fuerza *su* alineación.
> **Cuidado con el `sizeof` como fuente de alineación.** `struct Pair` (size 16) pide 8, no 16;
> `struct Quad` (size 16) pide 4, no 16. Si asumís "alineo a `sizeof`" vas a desperdiciar y, peor,
> a equivocarte con structs de tamaño impar (p. ej. size 24 con align 8). La alineación y el tamaño
> son primos hermanos, no gemelos.
#### Ejemplo paso a paso: alineación y tamaño de un struct
Vamos con un caso completo, miembro por miembro:
```c
struct Record {
int a; // 4 bytes
int b; // 4 bytes
double c; // 8 bytes
char d; // 1 byte
};
```
**Paso 1 — la alineación de cada miembro.** En x86/x64, lo habitual es:
| Miembro | Tipo | `_Alignof` |
|:-------:|:----:|:----------:|
| `a` | `int` | 4 |
| `b` | `int` | 4 |
| `c` | `double` | 8 |
| `d` | `char` | 1 |
**Paso 2 — la alineación del struct.** Es la **regla del mayor**: el alineamiento de una estructura
es el mayor alineamiento de entre todos sus miembros.
```text
max(4, 4, 8, 1) = 8
```
Por lo tanto `_Alignof(struct Record)` es **8**.
**Paso 3 — acomodar los miembros (y el relleno).** El compilador ubica cada miembro en el primer
offset que sea múltiplo de *su* alineación:
| Miembro | Alineación | Offset donde cae | Ocupa |
|:-------:|:----------:|:----------------:|:-----:|
| `a` | 4 | 0 | 0..3 |
| `b` | 4 | 4 | 4..7 |
| `c` | 8 | 8 | 8..15 |
| `d` | 1 | 16 | 16 |
| *relleno final* | — | 17 | 17..23 (7 bytes) |
Byte a byte queda así:
```text
offset 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |17 ... 23
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+----------+
| a (int) | b (int) | c (double) | d | relleno |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+----------+
align ^4 ^4 ^8 ^1
```
- `b` termina en 8 y `c` pide 8: **entra directo, sin relleno interno**.
- `d` ocupa 1 byte (offset 16), pero el struct completo tiene que medir un múltiplo de 8.
**Paso 4 — el `sizeof`.** Contenido real: `4 + 4 + 8 + 1 = 17`. Para que sea múltiplo de 8, se agregan
**7 bytes de relleno al final**:
```text
sizeof(struct Record) = 17 + 7 = 24
```
> **La trampa del relleno final.** El `sizeof` **no** es la suma de los miembros: incluye el relleno.
> Acá `4 + 4 + 8 + 1 = 17`, pero `sizeof` es **24**, no 16. Esos 7 bytes "fantasma" existen para que,
> en un arreglo `struct Record items[7]`, cada elemento arranque alineado a 8. Si te olvidás del
> relleno final, el número no cierra.
#### `_Alignof`: ¿operador, macro o función?
`_Alignof` **no es una función** (no se llama en runtime, no existe un símbolo con ese nombre) y
**tampoco es, en sí mismo, una macro**. Es un **operador unario**, igual que `sizeof`:
- Lleva el guión bajo (`_Alignof`) porque en C11 entró como **palabra clave reservada**: los
identificadores con `_` + mayúscula están reservados al lenguaje, así no choca con código viejo.
- Se evalúa en **tiempo de compilación** y produce un valor de tipo `size_t` (la cantidad de bytes de
alineación).
- Su operando es un **tipo** entre paréntesis (`_Alignof(double)`, `_Alignof(struct Record)`), **no**
una variable.
- El `_` inicial es feo a propósito. Por eso `<stdalign.h>` define el **macro** `alignof`, que se
expande a `_Alignof`. Ahí está tu respuesta fina: `alignof` **es** un macro; `_Alignof` es la
palabra clave. En C23 `alignof` pasó a ser palabra clave por derecho propio (y el macro se jubiló).
Cómo usarlo:
```c
#include <stdio.h>
#include <stdalign.h> // trae el macro `alignof`
typedef struct { int a; int b; double c; char d; } Record;
int main(void) {
printf("sizeof(int) = %zu\n", sizeof(int)); // 4
printf("alignof(double) = %zu\n", alignof(double)); // 8
printf("sizeof(Record) = %zu\n", sizeof(Record)); // 24
printf("alignof(Record) = %zu\n", alignof(Record)); // 8
return 0;
}
```
> **Detalle de `printf`.** `_Alignof` y `sizeof` devuelven `size_t`: imprimilos con `%zu`, no con
> `%d` (si no, el formato no coincide con el tipo y tenés *undefined behavior*).
> **Para la arena.** Este es el número que le pasás a `ARENA_ALIGN`: `_Alignof(max_align_t)` cubre
> cualquier tipo; `_Alignof(Record)` alcanza para los `Record`. Lo importante: la alineación se
> **lee del tipo**, no se adivina desde el `sizeof`.
#### La fórmula, para tontos
El corazón de todo es redondear **hacia arriba** al múltiplo más cercano:
```c
#define ARENA_ALIGN 8 // alineamos a 8 bytes (potencia de 2)
// Redondea n hacia arriba al múltiplo de ARENA_ALIGN más cercano.
#define ARENA_ALIGN_UP(n) (((n) + (ARENA_ALIGN - 1)) & ~(size_t)(ARENA_ALIGN - 1))
```
La parte `& ~(ARENA_ALIGN - 1)` parece magia negra, pero es solo aritmética de bits. Vamos despacio,
con `A = 8`:
1. **`A - 1 = 7 = 0b0111`.** Es una máscara con los **3 bits de abajo** en 1. Esos 3 bits son justo
los que "sobran" dentro de un bloque de 8 (los números 0..7).
2. **`n + (A - 1)` = `n + 7`.** Sumar 7 "empuja" el número hacia arriba lo suficiente para que,
cualquiera sea su resto dentro del bloque de 8, cruce al próximo límite. Si ya estaba justo en un
límite, el `+7` se queda adentro del mismo bloque y da lo mismo.
3. **`~(A - 1) = ~0b0111 = 0b...11111000`.** El `~` invierte: ahora esos 3 bits bajos están en 0 y el
resto en 1. Hacer `&` con esto **borra el resto**, deja los bits altos intactos.
4. **Resultado:** el mayor múltiplo de 8 que es ≤ `n+7`, o sea el **menor múltiplo de 8 ≥ `n`**.
Tabla, con bits de 4 para que se vea:
| `n` | `n + 7` | `n + 7` en binario | `& ~7` | resultado |
|:---:|:-------:|:------------------:|:------:|:---------:|
| 0 | 7 | `0111` | `0000` | **0** |
| 1 | 8 | `1000` | `1000` | **8** |
| 7 | 14 | `1110` | `1000` | **8** |
| 8 | 15 | `1111` | `1000` | **8** |
| 9 | 16 | `1 0000` | `1 0000` | **16** |
Ahí está toda la fórmula: **sumar `A-1` y borrar los bits bajos con `~`**. Nada más.
> **¿Por qué solo sirve con potencias de 2?** Porque `~(A-1)` solo "borra exactamente los bits del
> resto" cuando `A` es potencia de 2. Para `A = 10` esa máscara no existe y habría que dividir con
> `/` y `%`. Por suerte **todas** las alineaciones estándar son potencias de 2 (1, 2, 4, 8, 16...),
> así que el truco siempre aplica.
#### Aplicala a la posición, no al tamaño (variante robusta)
El capítulo redondea el **tamaño** y da por hecho que `offset` ya estaba alineado. Eso alcanza
mientras el bloque base esté alineado y todos pidan lo mismo. La versión más honesta **redondea el
cursor antes de repartir**, que es lo que de verdad querés: que el hueco *nuevo* empiece alineado.
```c
void *arena_alloc(Arena *a, size_t size) {
size_t aligned = ARENA_ALIGN_UP(size); // reservamos un poco más, para alinear
if (a->offset + aligned > a->capacity) {
// Subo el cursor del offset al múltiplo de 8 más cercano ...
size_t aligned = ARENA_ALIGN_UP(a->offset);
// ... y recién ahí veo si entra el pedido exacto.
if (aligned + size > a->capacity) {
return NULL;
}
void *ptr = a->buffer + a->offset;
a->offset += aligned;
void *ptr = a->buffer + aligned; // puntero al hueco ya alineado
a->offset = aligned + size; // el cursor queda donde termina el dato
return ptr;
}
```
La parte `& ~(ARENA_ALIGN - 1)` es un truco de bits: como 8 es potencia de 2, "redondear hacia
arriba a múltiplo de 8" se hace con una máscara. Es válido **solo porque la alineación es potencia
de 2**.
Diferencia con la versión anterior (la del capítulo, que redondea el tamaño): esta **desperdicia
menos** (usa `size` exacto y solo paga el *padding* necesario antes del bloque) y expresa mejor la
idea "alineá la **dirección** donde arranca el dato".
#### Ojo: el bloque base también manda
Todo lo anterior asume que `a->buffer` es múltiplo de 8. Como el puntero que devolvés es
`base + offset`, si `base` está torcido, **todo** queda torcido y alinear el offset no sirve de nada.
- `malloc` **garantiza** la alineación de cualquier tipo, así que un bloque pedido con `malloc` es
seguro.
- En cambio un `unsigned char buffer[4096];` local **puede** quedar alineado a 1, ¡aunque el array
sea grande! Si vas a prestar memoria (ejercicio 12.11), forzá la alineación o, mejor, alineá la
**dirección absoluta**, que se banca hasta una base torcida:
```c
#include <stdint.h> // uintptr_t
void *arena_alloc(Arena *a, size_t size) {
// Alineo la DIRECCIÓN real (base + offset), no solo el offset.
uintptr_t start = (uintptr_t)(a->buffer + a->offset);
uintptr_t aligned = (start + (ARENA_ALIGN - 1)) & ~(uintptr_t)(ARENA_ALIGN - 1);
size_t pad = aligned - start; // los bytes que "sacrifico" para alinear
if (a->offset + pad + size > a->capacity) {
return NULL;
}
a->offset += pad + size;
return (void *)aligned;
}
```
Ese `pad = aligned - start` **es** la respuesta literal a "cómo calculo la posición que debe
ocupar": la posición es `aligned`, y el costo de alinear es `pad`. Simple y sin depender de que
`base` haya venido alineado.
#### Lo que te llevás de alineación
- La alineación es del **tipo**, no del valor ni de la arena.
- Para mezclar tipos **no** calculás posiciones: elegís **una** alineación universal
(`_Alignof(max_align_t)`) y alineás cada reparto. Un múltiplo de 8 ya es múltiplo de 8, 4, 2 y 1.
- La alineación de un struct = la mayor de sus miembros, y **siempre divide a su `sizeof`**. Si el
bloque arranca alineado, los miembros caen alineados solos.
- La fórmula es `(n + A - 1) & ~(A - 1)`, y solo funciona con `A` potencia de 2.
- El bloque **base** también tiene que estar alineado; lo más robusto es alinear la dirección
absoluta, no solo el offset.
### 12.4.2 ¿Y si no entra lo que pedís?