1. Qué pregunta responde una medición
En el tema 8 construimos un bucle correcto de copia y en el 10 distinguimos los búferes de la aplicación, la biblioteca y el núcleo. Ahora vamos a cambiar tamaños sin perder esas garantías de corrección. Antes de ejecutar un cronómetro, conviene expresar exactamente qué trabajo se compara.
Una medición de E/S necesita especificar origen, destino, cantidad de datos, interfaz, política de búfer, intervalo cronometrado y condiciones de repetición. Un número en MiB/s sin ese contexto no describe una propiedad general del equipo.
Usaremos un archivo regular finito como origen y /dev/null como destino principal. Eso permite observar el coste de recorrer el archivo, entregar bloques al núcleo y descartar la salida, sin introducir escrituras persistentes de una copia. Los datos pueden proceder de la caché de páginas y no necesariamente del dispositivo en cada vuelta.
Qué podremos afirmar
Podremos contar cuántas llamadas read y write hizo nuestro bucle y medir su tiempo transcurrido bajo las condiciones de la prueba. Podremos comparar estrategias sobre el mismo origen estable. No podremos llamar a esa cifra «velocidad de escritura del SSD»: /dev/null no conserva los bytes y el experimento no espera persistencia.
Tampoco mediremos un coste intrínseco universal de una llamada al sistema. El tiempo observado incluye trabajo del bucle, comprobaciones, movimiento de datos, ejecución del núcleo, posibles esperas y cierres. Más adelante veremos cómo separar hipótesis y contrastar un modelo con los datos.
La corrección se conserva durante la prueba
Los medidores comprueban retornos, reintentan EINTR según su política y detectan fallos de cierre. No se considera una ejecución fallida como una muestra rápida. El tamaño de búfer no autoriza a ignorar una lectura corta ni una escritura parcial.
Enviar a /dev/null no demuestra por comparación que se conservaron los bytes de una copia. La corrección del algoritmo se contrasta con pruebas controladas y con herramientas como las del tema 8; esta prueba está diseñada para estudiar costes, no para sustituir la verificación de contenido.
2. Un modelo de coste por llamada y por byte
Supongamos un archivo estable de N bytes y peticiones de B bytes. En el caso ideal de lecturas regulares completas salvo la última, escrituras completas y ninguna interrupción, el número de bloques de datos es q = ceil(N / B). Nuestro bucle hace q lecturas positivas, una lectura final de EOF y q escrituras.
bloques con datos: q = ceil(N / B)
llamadas read: q + 1
llamadas write: q
total read + write: 2q + 1
tiempo aproximado:
T ≈ T_fijo + (2q + 1) × coste_por_llamada + N / caudal_del_recorrido
El término fijo agrupa operaciones que no escalan como una llamada por bloque. El último término representa el trabajo dependiente de los bytes. Es un modelo explicativo, no una identidad exacta: read y write pueden tener costes diferentes, las comprobaciones de usuario cuentan y las cachés o las esperas cambian el recorrido.
| B para N = 4 MiB | Read ideales | Write ideales | Total |
|---|---|---|---|
| 1 byte | 4.194.305 | 4.194.304 | 8.388.609 |
| 64 bytes | 65.537 | 65.536 | 131.073 |
| 4 KiB | 1.025 | 1.024 | 2.049 |
| 64 KiB | 65 | 64 | 129 |
| 1 MiB | 5 | 4 | 9 |
Son cantidades calculadas, no mediciones de hardware. En el programa real las interrupciones y parciales pueden aumentar los contadores. Para un archivo vacío, q es cero: el bucle hace una lectura de EOF y ninguna escritura.
Por qué aparece una meseta
Cuando las llamadas son numerosas, agrupar puede producir una gran mejora. Cuando su coste ya es pequeño frente al de mover o esperar los bytes, duplicar B apenas reduce el tiempo total. Llegar a esa meseta no significa que el programa haya dejado de copiar: significa que la parte que estás reduciendo ya no domina.
Un búfer mayor también ocupa más memoria por transferencia activa. Si miles de tareas reservan uno, una mejora mínima de tiempo puede costar una cantidad importante de memoria. No basta con observar que el contador baja.
3. Tiempo, caudal, CPU y unidades
El tiempo transcurrido mide cuánto espera el usuario entre dos puntos. El tiempo de CPU mide ejecución atribuida al proceso, a menudo separada en usuario y sistema. Una operación que espera almacenamiento puede consumir poco tiempo de CPU y aun así tardar bastante.
El caudal de esta prueba se calculará como N / (1024 × 1024 × segundos), expresado en MiB/s. Un MB decimal contiene 1.000.000 de bytes y un MiB 1.048.576; no mezcles unidades al comparar resultados.
Latencia y capacidad de transferencia no son iguales
Un búfer grande puede mejorar el caudal de un archivo completo y al mismo tiempo retrasar la entrega de mensajes pequeños si se espera a llenarlo. Un visor interactivo y una copia por lotes pueden preferir políticas distintas. El objetivo de optimización debe corresponder a la tarea.
Un promedio por llamada no aísla su coste
Dividir todo el tiempo por read_calls + write_calls produce un tiempo medio del recorrido repartido entre llamadas, no el coste puro de cruzar al núcleo. Si mueves los mismos bytes con muchas menos llamadas, el trabajo por llamada es diferente y el cociente puede incluso crecer mientras mejora el tiempo total.
Una estimación mediante diferencias entre dos tamaños requiere suponer que el trabajo por byte y las demás condiciones fueron comparables. Si hay dispersión, cachés o una diferencia de tiempos muy pequeña, la estimación puede ser inestable. Presenta esa inferencia con sus supuestos, no como una constante del procesador.
4. Medir con un reloj monotónico
Para intervalos usaremos clock_gettime con CLOCK_MONOTONIC. No representa una fecha de calendario y evita que un ajuste del reloj civil se confunda con una transferencia que duró menos o retrocedió en el tiempo. La resolución y el coste efectivo de consultar el reloj dependen de la plataforma.
struct timespec inicio, fin;
clock_gettime(CLOCK_MONOTONIC, &inicio);
/* trabajo medido */
clock_gettime(CLOCK_MONOTONIC, &fin);
double segundos = (double)(fin.tv_sec - inicio.tv_sec)
+ (double)(fin.tv_nsec - inicio.tv_nsec) / 1e9;
El fragmento omite las comprobaciones para mostrar la fórmula. Los programas completos verificarán cada consulta y rechazarán un intervalo no positivo. El manual de clock_gettime(2) describe los relojes y sus propiedades.
Fijar las fronteras del cronómetro
Prepararemos memoria, abriremos recursos y validaremos el origen antes del inicio. Cronometraremos el bucle de transferencia y los cierres finales. Imprimiremos el CSV después de detener el reloj. Así no confundimos el coste de mostrar cada fila con el trabajo que esa fila pretende describir.
Esto mide una ventana concreta; no incluye el arranque del proceso ni toda la preparación. Un cronómetro externo puede dar otro valor porque incluye más trabajo. Los dos pueden ser correctos si se explica qué intervalo utiliza cada uno.
No llenar la medición de observaciones
No imprimiremos una línea por bloque. La terminal, el formato de números y sus propios búferes pueden dominar el experimento. Mantendremos contadores en memoria y un informe por repetición fuera de la ventana.
5. Un medidor de read y write en C
Medir-bufer.c recibe archivo, tamaño de petición y repeticiones. Acepta tamaños de 1 byte a 1 MiB y de 1 a 20 repeticiones. En cada repetición vuelve a abrir el origen desde el principio y utiliza /dev/null como salida. Solo admite un origen regular y supone que permanece estable.
Los contadores incluyen los intentos de read y write realizados por el bucle, incluido el read final de EOF y los reintentos por EINTR. No incluyen open, fstat, close ni las operaciones internas de producir el CSV. Los cierres sí están dentro del tiempo, tal como acabamos de definir.
#define _POSIX_C_SOURCE 200809L
#define _FILE_OFFSET_BITS 64
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <time.h>
#include <errno.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
static int numero(const char *texto, unsigned long maximo, unsigned long *valor)
{
char *fin;
errno = 0;
*valor = strtoul(texto, &fin, 10);
return errno == 0 && fin != texto && *fin == '\0' &&
texto[0] != '-' && *valor >= 1 && *valor <= maximo;
}
static int cerrar(int *fd)
{
if (*fd == -1)
return 0;
int actual = *fd;
*fd = -1;
if (close(actual) == -1) {
perror("close");
return -1;
}
return 0;
}
int main(int argc, char *argv[])
{
unsigned long tam, repeticiones;
if (argc != 4 || !numero(argv[2], 1048576, &tam) ||
!numero(argv[3], 20, &repeticiones)) {
fprintf(stderr, "uso: %s ARCHIVO BUFFER:1..1048576 REP:1..20\n", argv[0]);
return 2;
}
unsigned char *buffer = malloc((size_t)tam);
if (buffer == NULL) {
perror("malloc");
return 1;
}
int estado = 0;
puts("rep,buffer,bytes,read_calls,write_calls,seconds,mib_s");
for (unsigned long rep = 1; rep <= repeticiones; rep++) {
int entrada = -1, salida = -1;
int correcto = 0;
uintmax_t bytes = 0, lecturas = 0, escrituras = 0;
struct timespec inicio, fin;
entrada = open(argv[1], O_RDONLY | O_NONBLOCK | O_CLOEXEC);
if (entrada == -1) {
perror("open entrada");
goto limpiar;
}
struct stat s;
if (fstat(entrada, &s) == -1) {
perror("fstat");
goto limpiar;
}
if (!S_ISREG(s.st_mode)) {
fprintf(stderr, "el origen debe ser un archivo regular\n");
goto limpiar;
}
salida = open("/dev/null", O_WRONLY | O_CLOEXEC);
if (salida == -1) {
perror("open /dev/null");
goto limpiar;
}
if (clock_gettime(CLOCK_MONOTONIC, &inicio) == -1) {
perror("reloj inicial");
goto limpiar;
}
for (;;) {
lecturas++;
ssize_t n = read(entrada, buffer, (size_t)tam);
if (n == -1) {
if (errno == EINTR)
continue;
perror("read");
goto limpiar;
}
if (n == 0)
break;
size_t enviados = 0;
while (enviados < (size_t)n) {
escrituras++;
ssize_t m = write(salida, buffer + enviados, (size_t)n - enviados);
if (m > 0) {
enviados += (size_t)m;
bytes += (uintmax_t)m;
continue;
}
if (m == -1 && errno == EINTR)
continue;
if (m == 0)
errno = EIO;
perror("write");
goto limpiar;
}
}
int cierre_salida = cerrar(&salida);
int cierre_entrada = cerrar(&entrada);
if (cierre_salida == -1 || cierre_entrada == -1)
goto limpiar;
if (clock_gettime(CLOCK_MONOTONIC, &fin) == -1) {
perror("reloj final");
goto limpiar;
}
double segundos = (double)(fin.tv_sec - inicio.tv_sec) +
(double)(fin.tv_nsec - inicio.tv_nsec) / 1e9;
if (segundos <= 0) {
fprintf(stderr, "intervalo no positivo; la muestra no es valida\n");
goto limpiar;
}
double caudal = (double)bytes / 1048576.0 / segundos;
printf("%lu,%lu,%ju,%ju,%ju,%.9f,%.3f\n",
rep, tam, bytes, lecturas, escrituras, segundos, caudal);
correcto = 1;
limpiar:
if (cerrar(&salida) == -1)
correcto = 0;
if (cerrar(&entrada) == -1)
correcto = 0;
if (!correcto) {
estado = 1;
break;
}
}
free(buffer);
if (fflush(stdout) == EOF || ferror(stdout)) {
fprintf(stderr, "error al guardar el CSV\n");
estado = 1;
}
return estado;
}
Qué se deja fuera deliberadamente
La reserva de memoria y la apertura no están cronometradas. Algunas primeras páginas del búfer pueden materializarse cuando se usan durante la transferencia; ese coste sí puede entrar en la primera muestra. Volver a abrir no vacía la caché de páginas del archivo.
El programa no sincroniza un destino persistente porque no lo hay. Si necesitas medir una copia durable, debes cambiar la tarea, comprobar identidad y tipos como en mi-cp y fijar dónde entra la sincronización en el tiempo. No basta con reemplazar /dev/null por una ruta arbitraria y seguir describiendo la misma prueba.
6. Laboratorio: contar y comparar bloques
Usa Bash en Linux o WSL. Crea una carpeta nueva y guarda medir-bufer.c dentro antes de compilar. Las pruebas usan unos pocos MiB; no necesitan permisos de administrador ni cambios en la configuración de caché del sistema.
$ LAB11=$(mktemp -d "$HOME/tema11.XXXXXX")
$ cd "$LAB11"
$ dd if=/dev/zero of=datos.bin bs=1M count=4 status=none
$ printf 'ABCDEFGHIJKLM' >>datos.bin
$ wc -c < datos.bin
4194317
$ gcc -std=c11 -Wall -Wextra -Wpedantic -O2 medir-bufer.c -o medir-bufer
$ ./medir-bufer datos.bin 65536 3
La salida comienza con las columnas rep,buffer,bytes,read_calls,write_calls,seconds,mib_s. Los tiempos se medirán en tu equipo; no hay una cifra universal esperada. Para ese origen y peticiones de 65536, en el caso sin parciales ni interrupciones, se esperan 65 bloques con datos, 66 read y 65 write por repetición.
Observar el extremo pequeño sin una prueba desproporcionada
$ head -c 16384 /dev/zero >corto.bin
$ ./medir-bufer corto.bin 1 3
$ ./medir-bufer corto.bin 4096 3
Con un byte se esperan 16385 read y 16384 write. Con 4096, cinco read y cuatro write. Los tiempos muy pequeños pueden tener mucha dispersión relativa; estas ejecuciones son útiles para ver los contadores, no para sostener una conclusión precisa a partir de una sola fracción de milisegundo.
Comprobar los límites y los fallos
Prueba un tamaño cero, uno mayor que 1 MiB y una ruta inexistente. Los argumentos inválidos devuelven 2; los errores de operación, 1. Un archivo vacío genera una muestra con cero bytes y caudal cero, aunque el tiempo de la lectura de EOF y los cierres sea positivo.
Las filas solo se imprimen para transferencias que completaron la ventana definida. Si una repetición falla, el programa devuelve 1 y puede haber dejado filas anteriores correctas. Un analizador no debe llamar «barrido completo» a un archivo parcial sin revisar también el estado de la ejecución.
7. Un barrido con orden alternado
Ejecutar siempre el tamaño pequeño primero y el grande al final puede mezclar una tendencia temporal con el efecto del tamaño: calentamiento de caché, carga del equipo, frecuencia o temperatura. El script siguiente realiza una pasada de preparación y cinco rondas, alternando el orden de los tamaños.
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
if (( $# != 1 )); then
printf 'Uso: %s ARCHIVO\n' "$0" >&2
exit 2
fi
tamanos=(64 256 1024 4096 16384 65536 262144 1048576)
./medir-bufer "$1" 65536 1 >/dev/null
printf 'ronda,rep,buffer,bytes,read_calls,write_calls,seconds,mib_s\n'
for ronda in 1 2 3 4 5; do
if (( ronda % 2 == 1 )); then
orden=("${tamanos[@]}")
else
orden=()
for (( i=${#tamanos[@]}-1; i>=0; i-- )); do
orden+=("${tamanos[i]}")
done
fi
for tam in "${orden[@]}"; do
./medir-bufer "$1" "$tam" 1 |
awk -v ronda="$ronda" 'NR > 1 { print ronda "," $0 }'
done
done
Se ejecuta desde el laboratorio, donde está ./medir-bufer. Awk elimina la cabecera de cada ejecución y añade la ronda. No interpreta nombres de archivo ni la salida visual de ls. Pipefail impide que un fallo del medidor quede oculto por el éxito de awk, y set -e detiene ese barrido ante el error.
$ bash -n barrer-bufer.sh
$ bash barrer-bufer.sh datos.bin >resultados.csv
$ echo $?
0
$ head -3 resultados.csv
Guarda resultados.csv en una ruta distinta del origen: la redirección del shell puede truncar su destino antes de iniciar el script. Si el estado no fue cero, revisa los diagnósticos y no presentes las filas disponibles como si fueran todas las rondas previstas.
Preparar no significa garantizar una caché concreta
La pasada inicial favorece que este archivo pequeño se haya recorrido antes, pero no congela la memoria ni garantiza que todas las vueltas siguientes estén íntegramente en RAM. Un origen mayor que la memoria disponible, otros procesos o decisiones del núcleo pueden cambiar la situación.
No vaciaremos cachés globales ni necesitaremos sudo. Las lecturas en frío y en caliente requieren su propio diseño y se estudian en el tema 13. Aquí se informa que hubo una preparación y que las repeticiones comparten el entorno del mismo equipo.
8. Resumir repeticiones sin elegir la mejor
El mínimo muestra una ejecución favorable, pero elegir solo ese valor puede ocultar lo que ocurre normalmente. Usaremos la mediana de cinco tiempos, el mínimo, el máximo y la desviación absoluta mediana, MAD: la mediana de las distancias de cada muestra respecto a la mediana.
Una MAD pequeña no demuestra ausencia de todos los sesgos. Con cinco muestras tampoco obtenemos automáticamente un intervalo de confianza. Sirve para acompañar el centro con una observación de dispersión y para reconocer diferencias menores que la variabilidad.
import csv
import math
import statistics
import sys
if len(sys.argv) != 2:
raise SystemExit("uso: python3 resumir-bufer.py RESULTADOS.csv")
grupos = {}
cantidades = set()
try:
with open(sys.argv[1], newline="", encoding="utf-8") as archivo:
for fila in csv.DictReader(archivo):
tam = int(fila["buffer"])
cantidad = int(fila["bytes"])
tiempo = float(fila["seconds"])
lecturas = int(fila["read_calls"])
escrituras = int(fila["write_calls"])
ronda = int(fila["ronda"])
if (tam < 1 or cantidad < 0 or lecturas < 1 or escrituras < 0
or not math.isfinite(tiempo) or tiempo <= 0
or ronda not in range(1, 6)):
raise ValueError("fila fuera de los limites esperados")
grupo = grupos.setdefault(tam, {})
if ronda in grupo:
raise ValueError("ronda duplicada para un tamano")
grupo[ronda] = tiempo
cantidades.add(cantidad)
esperados = {64, 256, 1024, 4096, 16384, 65536, 262144, 1048576}
if set(grupos) != esperados or len(cantidades) != 1:
raise ValueError("faltan tamanos o no se midio la misma cantidad de bytes")
if any(set(g) != {1, 2, 3, 4, 5} for g in grupos.values()):
raise ValueError("cada tamano debe tener las cinco rondas")
except (OSError, ValueError, KeyError, TypeError) as error:
raise SystemExit(f"CSV no valido o incompleto: {error}")
cantidad = cantidades.pop()
base = statistics.median(grupos[min(grupos)].values())
print("buffer mediana_ms minimo_ms maximo_ms MAD_ms MiB/s mejora_vs_64")
for tam in sorted(grupos):
tiempos = list(grupos[tam].values())
mediana = statistics.median(tiempos)
mad = statistics.median(abs(t - mediana) for t in tiempos)
caudal = cantidad / 1048576 / mediana
print(f"{tam:7d} {mediana*1000:10.3f} {min(tiempos)*1000:10.3f} "
f"{max(tiempos)*1000:10.3f} {mad*1000:7.3f} "
f"{caudal:8.2f} {base/mediana:8.2f}x")
$ python3 resumir-bufer.py resultados.csv
El analizador exige las ocho configuraciones y cinco rondas únicas por cada una. También comprueba que se transfirió la misma cantidad de bytes, pero eso no prueba que un origen activo no haya cambiado su contenido conservando la longitud. El laboratorio presupone un archivo estable.
Cómo leer una mejora
Si una configuración tarda la mitad que otra, su factor de mejora en tiempo es 2. El caudal se calcula con los mismos bytes y la mediana del tiempo. No sumes los caudales de las rondas ni los presentes como si fueran transferencias simultáneas.
Cuando dos medianas son próximas y sus muestras se solapan ampliamente, la prueba puede no resolver cuál es mejor. Repetir o ampliar una muestra tiene sentido si existe esa incertidumbre; no hasta obtener por azar el orden que esperabas.
9. Medidor con stdio: dos tamaños diferentes
En stdio hay que separar el tamaño pedido por la aplicación y la capacidad configurada para la biblioteca. Comparar-stdio.c admite los modos caracteres y bloques. El primero usa fgetc/fputc; el segundo fread/fwrite con un bloque de aplicación fijo de 16 KiB. En ambos se configura un búfer de biblioteca propio para cada flujo.
Sus contadores son llamadas a funciones de stdio, no llamadas al sistema. El intervalo empieza después de abrir y configurar y termina después de fflush y fclose de ambos flujos. Usa el mismo archivo regular estable y /dev/null, por lo que tampoco mide escrituras durables del dispositivo.
#define _POSIX_C_SOURCE 200809L
#define _FILE_OFFSET_BITS 64
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <time.h>
#include <inttypes.h>
static int numero(const char *texto, unsigned long maximo, unsigned long *valor)
{
char *fin;
errno = 0;
*valor = strtoul(texto, &fin, 10);
return errno == 0 && fin != texto && *fin == '\0' &&
texto[0] != '-' && *valor >= 1 && *valor <= maximo;
}
static int cerrar(FILE **f)
{
if (*f == NULL)
return 0;
FILE *actual = *f;
*f = NULL;
if (fclose(actual) == EOF) {
perror("fclose");
return -1;
}
return 0;
}
int main(int argc, char *argv[])
{
unsigned long tam, repeticiones;
if (argc != 5 || (strcmp(argv[2], "caracteres") != 0 &&
strcmp(argv[2], "bloques") != 0) ||
!numero(argv[3], 1048576, &tam) || !numero(argv[4], 20, &repeticiones)) {
fprintf(stderr, "uso: %s ARCHIVO caracteres|bloques BUFFER_BIB REP\n", argv[0]);
return 2;
}
int caracteres = strcmp(argv[2], "caracteres") == 0;
char *buf_entrada = malloc((size_t)tam);
char *buf_salida = malloc((size_t)tam);
if (buf_entrada == NULL || buf_salida == NULL) {
perror("malloc");
free(buf_entrada);
free(buf_salida);
return 1;
}
int estado = 0;
puts("rep,mode,lib_buffer,bytes,input_calls,output_calls,seconds,mib_s");
for (unsigned long rep = 1; rep <= repeticiones; rep++) {
FILE *entrada = NULL, *salida = NULL;
uintmax_t bytes = 0, lecturas = 0, escrituras = 0;
int correcto = 0;
struct timespec inicio, fin;
entrada = fopen(argv[1], "rb");
if (entrada == NULL) {
perror("fopen entrada");
goto limpiar;
}
if (setvbuf(entrada, buf_entrada, _IOFBF, (size_t)tam) != 0) {
fprintf(stderr, "setvbuf entrada fallo\n");
goto limpiar;
}
struct stat s;
if (fstat(fileno(entrada), &s) == -1) {
perror("fstat entrada");
goto limpiar;
}
if (!S_ISREG(s.st_mode)) {
fprintf(stderr, "el origen debe ser un archivo regular\n");
goto limpiar;
}
salida = fopen("/dev/null", "wb");
if (salida == NULL) {
perror("fopen /dev/null");
goto limpiar;
}
if (setvbuf(salida, buf_salida, _IOFBF, (size_t)tam) != 0) {
fprintf(stderr, "setvbuf salida fallo\n");
goto limpiar;
}
if (clock_gettime(CLOCK_MONOTONIC, &inicio) == -1) {
perror("reloj inicial");
goto limpiar;
}
if (caracteres) {
for (;;) {
lecturas++;
int c = fgetc(entrada);
if (c == EOF)
break;
escrituras++;
if (fputc(c, salida) == EOF) {
perror("fputc");
goto limpiar;
}
bytes++;
}
} else {
unsigned char bloque[16384];
for (;;) {
lecturas++;
size_t n = fread(bloque, 1, sizeof bloque, entrada);
if (n > 0) {
escrituras++;
if (fwrite(bloque, 1, n, salida) != n) {
fprintf(stderr, "fwrite incompleto\n");
goto limpiar;
}
bytes += (uintmax_t)n;
}
if (ferror(entrada) || feof(entrada))
break;
}
}
if (ferror(entrada)) {
fprintf(stderr, "error de lectura stdio\n");
goto limpiar;
}
if (fflush(salida) == EOF) {
perror("fflush salida");
goto limpiar;
}
int cierre_salida = cerrar(&salida);
int cierre_entrada = cerrar(&entrada);
if (cierre_salida == -1 || cierre_entrada == -1)
goto limpiar;
if (clock_gettime(CLOCK_MONOTONIC, &fin) == -1) {
perror("reloj final");
goto limpiar;
}
double segundos = (double)(fin.tv_sec - inicio.tv_sec) +
(double)(fin.tv_nsec - inicio.tv_nsec) / 1e9;
if (segundos <= 0) {
fprintf(stderr, "intervalo no positivo\n");
goto limpiar;
}
printf("%lu,%s,%lu,%ju,%ju,%ju,%.9f,%.3f\n",
rep, argv[2], tam, bytes, lecturas, escrituras,
segundos, (double)bytes / 1048576.0 / segundos);
correcto = 1;
limpiar:
if (cerrar(&salida) == -1)
correcto = 0;
if (cerrar(&entrada) == -1)
correcto = 0;
if (!correcto) {
estado = 1;
break;
}
}
free(buf_entrada);
free(buf_salida);
if (fflush(stdout) == EOF || ferror(stdout)) {
fprintf(stderr, "error al guardar el CSV\n");
estado = 1;
}
return estado;
}
Guarda y compila el programa en el laboratorio. La entrada de esta prueba debe ser un archivo regular conocido; fopen sobre una FIFO podría esperar antes de llegar a la comprobación de tipo. No uses una ruta de flujo desconocido para este experimento.
$ gcc -std=c11 -Wall -Wextra -Wpedantic -O2 comparar-stdio.c -o comparar-stdio
$ ./comparar-stdio datos.bin caracteres 65536 3
$ ./comparar-stdio datos.bin bloques 65536 3
$ ./comparar-stdio datos.bin bloques 4096 3
En modo caracteres hay una llamada fgetc por byte y otra que encuentra EOF, además de un fputc por byte. Eso no significa que haya un read y un write del núcleo por byte: la biblioteca puede agruparlos. Sigue existiendo trabajo de usuario por cada carácter, como comprobaciones y sincronización interna de las funciones.
En modo bloques, el tamaño de aplicación sigue siendo 16 KiB aunque cambies el búfer de biblioteca. La implementación puede entregar bloques grandes directamente y no multiplicar sus llamadas como supondría una fórmula ingenua. El manual de control de búferes de GNU libc describe setvbuf; no promete una correspondencia fija entre cada capacidad y cada llamada al núcleo.
Los búferes propios se mantienen vivos hasta que los flujos se cierran. Los contadores y tiempos de los dos programas permiten hacer preguntas comparables, pero las cantidades de input_calls de stdio y read_calls directas pertenecen a capas distintas.
10. Observar llamadas con strace
Si strace está instalado y permitido en tu entorno, puede resumir las llamadas del programa. Es una comprobación útil de lo que realmente llegó a la interfaz del núcleo, especialmente en el experimento con stdio.
$ strace -c -e trace=read,write ./medir-bufer corto.bin 4096 1
$ strace -c -e trace=read,write ./comparar-stdio corto.bin caracteres 4096 1
$ strace -c -e trace=read,write ./comparar-stdio corto.bin bloques 4096 1
El resumen puede incluir lecturas del arranque y escrituras del informe CSV, además del flujo medido. Nuestros contadores directos solo cubren su bucle. No atribuyas automáticamente cualquier diferencia a un fallo de la fórmula.
La traza añade coste y puede alterar mucho los tiempos, especialmente con peticiones pequeñas. El resumen de tiempo de strace tampoco es idéntico a nuestro intervalo monotónico; opciones como -w cambian qué tiempo resume. Consulta strace(1) y usa la herramienta para estudiar comportamiento, manteniendo separadas las ejecuciones principales de rendimiento.
Qué contraste buscar
La versión directa de un byte produce muchas operaciones read/write. La versión fgetc/fputc puede hacer muchas llamadas de biblioteca y bastantes menos llamadas al sistema gracias a su búfer. La versión fread/fwrite por bloques reduce también buena parte de las invocaciones de usuario.
Si la traza no está disponible, el medidor directo sigue proporcionando sus propios contadores y el experimento de stdio sigue siendo ejecutable. No inventes entonces un número exacto de llamadas internas de la biblioteca a partir del tamaño configurado.
11. Caché, destinos y otras fuentes de sesgo
Un archivo recién creado puede estar en memoria
Generar datos.bin y leerlo inmediatamente favorece que sus páginas estén en caché. Repetirlo suele medir un recorrido caliente si cabe y no es desalojado. No se puede extrapolar directamente a una lectura aleatoria de datos fríos en un dispositivo.
El siguiente bloque del curso estudia esa capa. Nuestra prueba no pretende neutralizarla con órdenes globales: la documenta como parte del recorrido y utiliza el mismo origen para comparar tamaños.
/dev/null no es un segundo disco
Descartar la salida elimina muchos costes de una escritura real. El núcleo no necesita conservar un archivo destino ni reservar sus bloques para esos datos. Una tasa elevada puede ser perfectamente compatible con un dispositivo de almacenamiento mucho más lento.
Un origen disperso también puede devolver ceros sin leer bloques físicos para todas sus regiones. Por eso el laboratorio crea datos mediante escritura ordinaria y no usa solo truncate como sustituto de un archivo escrito. Aun así, compresión, deduplicación y el entorno virtual pueden añadir sus propios comportamientos.
Ruido del equipo y del entorno
Otros procesos, cambios de frecuencia, límites térmicos, memoria disponible y el planificador afectan los tiempos. En WSL o una máquina virtual también intervienen capas del anfitrión. Anota el entorno y compara estrategias dentro de condiciones razonablemente estables.
Repetir indefinidamente hasta encontrar una muestra que confirme una conclusión no elimina el ruido. El orden alternado, los datos completos y una medida de dispersión permiten revisar qué se observó realmente.
Lo que la ventana incluye y excluye
Contar bytes y comprobar retornos cuesta tiempo y forma parte de nuestro código. Las aperturas quedan fuera, los cierres dentro y el CSV fuera. En stdio se incluye el vaciado final. Si omites fflush y fclose del tiempo y solo los haces después, puedes comparar aceptación en memoria con una transferencia ya entregada y sacar una conclusión equivocada.
La caché del núcleo puede seguir aceptando escrituras aunque una entrega de biblioteca haya terminado. Si decides medir persistencia, hay que cambiar y explicar las fronteras. No mezcles sin aviso resultados con y sin sincronización.
12. Cómo elegir un tamaño razonable
Busca primero la región donde el tiempo ya no mejora de forma relevante respecto a su dispersión. Entre tamaños con resultados equivalentes, el más pequeño puede ahorrar memoria y ofrecer una granularidad más conveniente. Es una decisión basada en el trabajo medido, no una regla de que un número concreto sea óptimo para todos.
- Comprobar corrección. Mismos datos, retornos válidos y fallos visibles. Una muestra incompleta no compite como resultado rápido.
- Comparar el coste que se quiere reducir. Revisa llamadas reales o de biblioteca según la capa.
- Identificar la meseta. Compara medianas y dispersión sin magnificar diferencias mínimas.
- Calcular memoria total. Multiplica el búfer por las transferencias simultáneas y suma otros búferes que también existan.
- Validar en la tarea real. Cambiar de /dev/null a almacenamiento, de secuencial a aleatorio o de una copia a muchas puede cambiar el equilibrio.
Memoria por flujo y memoria agregada
Un búfer de 1 MiB puede parecer pequeño en una copia y sumar 1 GiB con 1024 copias simultáneas. En stdio podrías tener un bloque de aplicación y búferes de entrada y salida. También existen cachés del núcleo y otros estados, que no están incluidos en esa cuenta simple.
Eso no significa que reducir memoria siempre mejore el tiempo. Significa que el resultado debe cumplir varias restricciones. El simulador permitirá hacer esa cuenta sin presentarla como si midiera concurrencia real o reparto de ancho de banda.
13. Simulador de coste y memoria
Este modelo hipotético usa el conteo ideal de read/write y una tasa efectiva del recorrido de datos. No consulta tu equipo ni mide un disco. Compara una tabla de tamaños y marca el menor que deja el coste de llamadas en un 5 % o menos del tiempo total y cabe en el presupuesto de memoria del modelo.
Menos llamadas, una meseta y memoria por transferencia
| Búfer | Read + write | Tiempo modelo | Coste llamadas | MiB/s | Memoria total |
|---|
El coste por llamada agrupa de forma simplificada operaciones que en realidad pueden tener costes distintos. La tasa efectiva representa todo el trabajo proporcional al tamaño y no se identifica con una especificación física concreta. No se modelan interrupciones, parciales, calentamiento, competencia de CPU o caché, ni cambios de estrategia de una biblioteca.
El control de transferencias simultáneas solo multiplica la memoria reservada. No acelera ni ralentiza el tiempo por transferencia del modelo: predecir el rendimiento concurrente exigiría modelar recursos compartidos. La marca del 5 % es un criterio didáctico configurable conceptualmente, no una recomendación universal de programación.
Qué predicciones contrastar
- Aumenta el coste de llamada. El tamaño necesario para que ese término pese poco tiende a crecer.
- Reduce la tasa efectiva del recorrido. El coste proporcional a los bytes domina más y la ganancia relativa de reducir llamadas disminuye.
- Reduce el coste de llamada a cero. El tiempo ya no mejora al aumentar B en este modelo, pero la memoria sigue aumentando.
- Aumenta las transferencias para memoria sin cambiar los demás controles. Los tiempos quedan iguales y algunas filas dejan de caber en el presupuesto.
- Aumenta el coste fijo. En transferencias pequeñas puede dominar incluso si las llamadas ya son pocas.
14. Resumen
- Un resultado de rendimiento necesita una tarea, una unidad y unas fronteras de tiempo explícitas.
- En el caso ideal de nuestro bucle, q bloques generan q + 1 read y q write; los parciales e interrupciones pueden aumentar la cuenta.
- Agrupar reduce coste por llamada hasta que domina otro término. Un búfer mayor no garantiza una mejora proporcional.
- CLOCK_MONOTONIC sirve para intervalos; el informe se produce fuera del tramo medido.
- El caudal de archivo a /dev/null no es una medición de escritura persistente del dispositivo.
- Las repeticiones, el orden alternado y la dispersión ayudan a evaluar resultados sin elegir solo la muestra favorable.
- La mediana resume el centro; mínimo, máximo y MAD aportan contexto y no sustituyen todos los análisis de incertidumbre.
- En stdio, petición de aplicación, búfer de biblioteca y llamadas al sistema son cantidades distintas.
- Strace permite observar operaciones, pero añade coste y puede incluir el arranque y el informe.
- El tamaño razonable depende de la meseta observada, la memoria agregada, la latencia requerida y la tarea real.
15. Preguntas de repaso y práctica
Antes de desplegar cada respuesta, identifica la capa, la unidad y el trabajo concreto del que se está hablando.
1. ¿Qué mide nuestro primer programa al usar /dev/null como salida?
El tiempo de recorrer un archivo regular y entregar sus bloques a una salida que los descarta, incluidos los cierres definidos. No mide una escritura persistente de una copia en un disco.
2. Para N = 10000 y B = 4096, ¿cuántos read y write ideales hay?
Tres bloques de datos: tres read positivos, uno final de EOF y tres write. En total cuatro read, tres write y siete operaciones de transferencia.
3. ¿Por qué el contador real puede superar esa fórmula?
Porque puede haber lecturas cortas, escrituras parciales e intentos interrumpidos que se reintentan. La fórmula describe un caso ideal, no elimina el contrato de read y write.
4. ¿Una llamada de fgetc por byte implica un read del núcleo por byte?
No. La biblioteca puede anticipar una región y servir muchos caracteres desde memoria. Sigue habiendo trabajo de la función de usuario por cada invocación.
5. ¿El tamaño dado a setvbuf es el tamaño fijo de cada read o write?
No necesariamente. La implementación puede agrupar o entregar bloques grandes de otra manera. Para observar operaciones internas se necesita evidencia adicional como una traza.
6. ¿Por qué usamos CLOCK_MONOTONIC en lugar de una fecha civil?
Porque queremos medir intervalos y evitar que ajustes del reloj de calendario se confundan con el tiempo del trabajo. La resolución y el coste del reloj siguen siendo propiedades del entorno.
7. ¿Dividir todo el tiempo entre llamadas da su coste puro?
No. El numerador incluye trabajo por byte, bucles, comprobaciones, esperas y cierres. El cociente es una media del recorrido repartida entre llamadas, no una constante intrínseca.
8. ¿Por qué no se imprime una línea por bloque dentro del intervalo?
Porque el formato y la salida añadirían un coste ajeno que podría dominar y cambiar la comparación. Se acumulan contadores y se informa después.
9. ¿Volver a abrir el archivo vacía su caché de páginas?
No. Obtiene otra apertura y posición inicial, pero las páginas pueden seguir en memoria. Repetir lecturas de un archivo pequeño suele favorecer un recorrido caliente.
10. ¿Qué problema reduce alternar el orden de los tamaños?
Reduce la asociación sistemática entre un tamaño y el momento de la prueba, como calentamiento o cambios de carga. No elimina por completo todas las fuentes de sesgo.
11. ¿La mejor de cinco muestras describe necesariamente lo normal?
No. Puede representar una situación particularmente favorable. Por eso conservamos todas las rondas y acompañamos la mediana con medidas de dispersión.
12. ¿Qué es MAD?
La mediana de las distancias absolutas de las muestras respecto a su mediana. Resume dispersión robusta, pero no es por sí sola un intervalo de confianza.
13. ¿Por qué el analizador exige todas las rondas?
Para no presentar un barrido incompleto como una comparación completa. También rechaza duplicados y cantidades de bytes incompatibles entre muestras.
14. ¿Por qué strace puede mostrar más read/write que nuestros contadores?
Porque puede incluir operaciones de arranque y del CSV fuera del bucle, además de alterar el tiempo con la observación. Los alcances de ambos conteos no son necesariamente iguales.
15. ¿Qué significa que los tiempos formen una meseta?
Que seguir reduciendo el coste de llamadas ya aporta poco frente al trabajo que domina, dentro de la resolución y variabilidad observadas. No significa que todos los tamaños sean equivalentes en cualquier tarea.
16. ¿Cuánta memoria suman 1024 búferes de 1 MiB?
1 GiB solo en esos búferes. Puede haber además otros búferes de biblioteca, estados de aplicación y cachés del núcleo.
17. ¿Por qué incluir fflush y fclose en el medidor de stdio?
Para no detener el reloj mientras la salida todavía está pendiente en el búfer de usuario y para comprobar fallos tardíos. El intervalo sigue sin prometer persistencia física.
18. ¿Puede un tamaño con máximo caudal ser inadecuado para un programa interactivo?
Sí. La acumulación puede retrasar mensajes pequeños o aumentar memoria y granularidad de trabajo. La elección debe contemplar latencia y restricciones de la tarea.
Para practicar
- Ejecuta medir-bufer sobre archivos de 0, 1, 10000 y 16384 bytes con varios tamaños. Comprueba los conteos ideales y explica cualquier diferencia observable.
- Realiza el barrido completo y conserva resultados.csv. Verifica que hay cinco rondas por cada uno de los ocho tamaños antes de comparar medianas.
- Elimina una fila de una copia del CSV y duplica otra en una segunda copia. Comprueba que resumir-bufer.py rechaza ambos conjuntos.
- Compara tamaños vecinos de la meseta. Explica si la dispersión permite distinguirlos y qué criterio usarías si su tiempo es equivalente.
- Ejecuta comparar-stdio en caracteres y bloques con el mismo búfer de biblioteca. Separa invocaciones de stdio de llamadas al sistema.
- Si tienes strace, inspecciona las versiones con una entrada pequeña. No mezcles sus tiempos con las filas del barrido sin traza.
- Cambia el bloque de aplicación de fread/fwrite manteniendo setvbuf constante. Después haz lo contrario y explica qué variable modificaste en cada prueba.
- Calcula la memoria de 64, 512 y 1024 copias para tres tamaños que hayan dado tiempos similares. Incluye también los búferes extra si tu diseño usa stdio.
- En el simulador reduce el coste por llamada a cero y aumenta el búfer. Explica por qué cambia la memoria y no el tiempo del modelo.
- Diseña un experimento de copia a archivo real indicando dónde entrarían apertura, transferencia, fflush si corresponde, fsync, cierre y verificación. Distingue esa tarea de la prueba con /dev/null.
- Anota el entorno de tus mediciones y qué conclusiones no puedes sostener sobre lecturas en frío, concurrencia o almacenamiento persistente a partir de ellas.
Con este tema termina el bloque de entrada/salida básica. En el tema 12 estudiaremos la caché de páginas: por qué una lectura puede atenderse desde RAM, dónde quedan las escrituras y cómo esa capa cambia la interpretación de las mediciones que acabamos de construir.