Sobre el tamaño de bloque en dd

16 de marzo de 2021 • fjbalon

dd es una de las herramientas invaluables de cualquier arsenal Unix, y quizás una de las herramientas más potentes que existen en esta familia. Forma parte de las core utils y su objetivo principal es convertir y copiar archivos -de manera similar a cp-, dd hace una copia bit a bit con funciones de control de flujo de entrada y salida a bajo nivel.

Permite entonces realizar tareas generales, como quemar una imagen -generalmente ISO- en un medio de instalación USB.

dd bs=512K if=/image.iso of=/dev/sdx conv=fsync oflag=direct status=progress

El uso de dd es simple, indicamos como parámetros un archivo de entrada (input file - if) y uno de salida (output file - of). De tal forma, también permite clonar un disco completo en el sistema de ficheros, siempre que indiquemos como if el disco físico (por ejemplo /dev/sdx) y como of la imagen a crear. Para mayor eficiencia y uso de recursos de espacio de disco, lo comprimimos con gz (tenga en cuenta que gzip sólo comprime utilizando un núcleo de la CPU, por lo que en discos de tamaño considerable, recomiendo utilizar otra herramienta como pigz para optimizar al máximo el tiempo de compresión):

dd bs=512K if=/dev/sdx status=progress | gzip -c  > /image.img.gz

Del mismo modo, intercambiando if y of podemos quemar esta imagen comprimida en cualquier otro disco, sea /dev/sdy:

gunzip -c /image.img.gz | dd bs=512K of=/dev/sdy status=progress

Si lo único que deseamos es clonar de un disco a otro, podemos evitar el intermediario y hacerlo directamente jugando con if y of, dados los discos /dev/sdx y /dev/sdy:

dd bs=512K if=/dev/sdx of=/dev/sdy status=progress

Quizá sea porque hay que ser extremadamente cauteloso utilizando dd, ya que es sencillo destruir datos de forma irreversible en caso de negligencia; lo que hace que muchas veces nos enfoquemos únicamente en acertar con el parámetro of (archivo de salida - output file), o quizá sea pereza mental, pero un aspecto que se pasa normalmente por alto es el relativo a los tres argumentos de tamaño de bloque: ibs (tamaño de bloque de entrada), obd (tamaño de bloque de salida) y bs (tamaño de bloque de entrada y salida, abarcándolo todo).

Partamos de la base de que un bloque es una unidad que mide el número de bytes que se leen, escriben o convierten a la vez. De forma general y predeterminada, dd utiliza un tamaño de bloque de 512 bytes, tanto para la entrada como para la salida. Esto quizás ha sido correcto entre 1980 y 1999, cuando la mayoría de discos tenían un tamaño de sector de 512 bytes, sin embargo en los últimos años la mayoría de los discos tienen un tamaño de sector de al menos 4096 bytes (4K). Aunque el cambio puede parecer intrascendente, puede conducir a ineficiencia e inestabilidad, más teniendo en cuenta que los discos actuales pueden tener más de 1TB de capacidad, lo que requiere un tamaño de bloque más óptimo.

Por convención, hoy en día se recomienda utilizar un tamaño de bloque de 64K (en ocasiones se indica como 128K), asegurándose como más óptimos. Sin embargo, al no existir contexto ni explicación, lo considero un argumento viciado y arbitrario, teniendo en cuenta que no existe un tamaño de bloque universalmente correcto: lo que es óptimo para una situación puede ser terriblemente ineficiente para otra.

Si tomamos como ejemplo de lectura el fichero /dev/zero y lo escribimos en un disco de 1TB SSD, producimos un rendimiento de 39,6 MB/s tomando el tamaño de bloque predeterminado de 512 bytes. Esto daría una duración de 26.479 segundos, unas 26 horas. Para el mismo caso pero en un disco de 1TB HDD, el rendimiento descendería a 10,9 MB/s, es decir, 96.200 segundos o unas 26 horas. Si tomamos el tamaño de bloque recomendado de 64K obtenemos un rendimiento de 266 MB/s (3.942s, ~1,1h) para SSD y 108 MB MB/s (9.709s, ~2,7h) para HDD. Si lo que buscamos es velocidad óptima, evidentemente cuanto mayor sea el tamaño de bloque, menos llevará a dd realizar la copia.

Para ilustrarnos técnicamente, dado el script:

#!/bin/bash

target=$1
origin=/dev/urandom # /dev/zero
file_SIZE=134217728 # 128MB

human_bytes () {
    unidades="b,K,M,G,T,P,E,Z,Y"
    i=1
    base=$(echo "$1 / 1" | bc)
    variable=$( echo $base / 1024 | bc )
    while [ $variable -gt 0 ]
    do
        let i=i+1
        base=$(echo "$base / 1024" | bc)
        variable=$(echo $base / 1024 | bc )
    done
    echo $base$(echo $unidades | cut -f$i -d, )
}

for BLOCK_SIZE in 512 1024 2048 4096 8192 16384 32768 65536 131072 262144 524288 1048576 2097152 4194304 8388608 16777216 33554432 67108864
do
    COUNT=$(($file_SIZE / $BLOCK_SIZE))

    if [ $COUNT -le 0 ]; then
    echo "Block size of $BLOCK_SIZE estimated to require $COUNT blocks, aborting further tests."
    break
    fi

    # Clear kernel cache to ensure more accurate test
    [ $EUID -eq 0 ] && [ -e /proc/sys/vm/drop_caches ] && echo 3 > /proc/sys/vm/drop_caches
    
    DD_RESULT=$(dd if=$origin of=$target bs=$BLOCK_SIZE count=$COUNT conv=fsync 2>&1 1>/dev/null)
    TRANSFER_RATE=$(echo $DD_RESULT | \grep --only-matching -E '[0-9.]+ ([MGk]?B|bytes)/s(ec)?')

    printf "$BLOCK_SIZE ($(human_bytes $BLOCK_SIZE)) : $TRANSFER_RATE \n"
done

Donde human_bytes se corresponde a una adaptación del script de «humanización» del resultado en bytes:

unidades=" bytes, KB, MB, GB, TB, PB, EB, ZB, YB"
i=1
base=$(echo "scale=2; $1 / 1" | bc)
variable=$( echo $base / 1024 | bc )
while [ $variable -gt 0 ]
do
    let i=i+1
    base=$(echo "scale=2; $base / 1024" | bc)
    variable=$(echo $base / 1024 | bc )
done
echo $base$(echo $unidades | cut -f$i -d, )

Cuyo resultado se basa en la escritura de datos provenientes de /dev/zero ó /dev/urandom en un disco pasado como argumento, de tal forma que analiza el rendimiento dado por el obs:

sudo ./dd-obs.sh /dev/sdd
512 (512b) : 7 MB/s 
1024 (1K) : 9 MB/s 
2048 (2K) : 0 MB/s 
4096 (4K) : 192 MB/s 
8192 (8K) : 205 MB/s 
16384 (16K) : 204 MB/s 
32768 (32K) : 202 MB/s 
65536 (64K) : 195 MB/s 
131072 (128K) : 201 MB/s 
262144 (256K) : 203 MB/s 
524288 (512K) : 200 MB/s 
1048576 (1M) : 200 MB/s 
2097152 (2M) : 197 MB/s 
4194304 (4M) : 203 MB/s 
8388608 (8M) : 193 MB/s 
16777216 (16M) : 197 MB/s 
33554432 (32M) : 189 MB/s 
67108864 (64M) : 194 MB/s

Lo que lleva a analizar que un tamaño de bloques definido entre 64K y 512K es suficientemente óptimo, tomando como referencia valores más altos en los que apenas se notaría la optimización (y como detallaré más adelante ofrecerá más riesgo) y valores inferiores, contra los que sí que se observa una clara diferencia, especialmente tomando el valor por defecto.

Para analizar el ibs, debemos indicar el disco como if y copiar el contenido of a /dev/null, teniendo una lógica similar a:

BLOCK_SIZE=65536
COUNT=$(($file_SIZE / $BLOCK_SIZE))
dd if=/dev/urandom of=$target bs=$BLOCK_SIZE count=$COUNT > /dev/null 2>&1

for BLOCK_SIZE in 512 1024 2048 4096 8192 16384 32768 65536 131072 262144 524288 1048576 2097152 4194304 8388608 16777216 33554432 67108864
do
    DD_RESULT=$(dd if=$target of=/dev/null bs=$BLOCK_SIZE 2>&1 1>/dev/null)
    TRANSFER_RATE=$(echo $DD_RESULT | \grep --only-matching -E '[0-9.]+ ([MGk]?B|bytes)/s(ec)?')
    printf "$BLOCK_SIZE ($(human_bytes $BLOCK_SIZE)) : $TRANSFER_RATE \n"
done

Ofreciendo un resultado similar, permitiéndonos ajustar el tamaño del bloque con mayor inteligencia:

sudo ./dd-ibs.sh example
512 (512b) : 275 MB/s 
1024 (1K) : 373 MB/s 
2048 (2K) : 276 MB/s 
4096 (4K) : 491 MB/s 
8192 (8K) : 134 MB/s 
16384 (16K) : 348 MB/s 
32768 (32K) : 451 MB/s 
65536 (64K) : 486 MB/s 
131072 (128K) : 333 MB/s 
262144 (256K) : 406 MB/s 
524288 (512K) : 257 MB/s 
1048576 (1M) : 439 MB/s 
2097152 (2M) : 188 MB/s 
4194304 (4M) : 152 MB/s 
8388608 (8M) : 135 MB/s 
16777216 (16M) : 193 MB/s 
33554432 (32M) : 120 MB/s 
67108864 (64M) : 8 MB/s

Hay que tener en cuenta que, si el tamaño de bloque es, digamos, 1M, dd leerá 1024x1024 bytes y escribirá bytes iguales. Pero si ocurriera un error de lectura, las cosas saldrán mal. Se tiende a pensar que dd llenará los errores de lectura con ceros si usa las opciones noerror y sync, sin embargo esto no es lo que sucede: dd rellenará el obs con el tamaño del ibs después de completar su lectura, lo que significa que agregará ceros al final del bloque. Es decir, para un disco todo el 1M se estropearía debido a un solo error de lectura de 512 bytes al comienzo de la lectura: 12ERROR89 se convertiría en 128900000 en lugar de 120000089.

Si tenemos la certeza de que el disco no contiene ningún error, podríamos continuar usando un tamaño de bloque más grande, lo que aumentará la velocidad de su copia varias veces, especialmente siguiendo los parámetros anteriores para una mayor eficiencia. Por ejemplo, en teoría cambiar el bs de 512 a 64K cambiará la velocidad de copia de 35MB/s a 120MB/s, tomando como ejemplo un sistema Celeron de 2.7 GHz. Pero tenga en cuenta que los errores de lectura en el disco de origen terminarán como «errores de bloque» en el disco de destino, es decir, un solo error de lectura de 512 bytes dañará todo el bloque de salida de 64K.