3. Cálculo y rendimiento en CPU
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
La lección 2 usó tensores sin preguntarse cuánto cuestan. Esta lección mira debajo: cuándo dos tensores comparten memoria, cuándo copia Candle, a qué se aferra el grafo de operaciones y cómo gasta sus hilos el backend de CPU. Después mide, en una máquina, con las versiones indicadas, porque las afirmaciones de rendimiento sin una máquina son opiniones.
El código está en examples/l03_views.rs, examples/l03_bench.rs y examples/l03_exercises.rs.
Vistas y copias
Sección titulada «Vistas y copias»Un Tensor es un buffer compartido más un Layout: una forma, un stride por dimensión (cuántos elementos del buffer salta un paso a lo largo de esa dimensión) y un offset (dónde está el primer elemento). Dos tensores pueden compartir el buffer con layouts distintos. El ejemplo averigua si lo hacen comparando la dirección del primer elemento del buffer detrás de cada tensor (líneas 7-14):
fn buffer(t: &Tensor) -> usize { let (storage, _layout) = t.storage_and_layout(); match &*storage { Storage::Cpu(CpuStorage::F32(v)) => v.as_ptr() as usize, Storage::Cpu(CpuStorage::F64(v)) => v.as_ptr() as usize, _ => unimplemented!("only f32 and f64 CPU tensors in this example"), }}Crear un tensor a partir de un Vec se queda con el Vec sin copiarlo, y crearlo a partir de un slice copia (líneas 30-40):
== from_vec keeps the Vec's buffersame buffer as the Vec: truefrom_slice, same buffer as the slice: falseAsí que from_vec es la forma de entregar a Candle un buffer grande que has construido tú; Tensor::new(&[...]) y from_slice siempre copian. Después, quince operaciones sobre un tensor a de forma (3, 4), que contiene de 0 a 11 (líneas 42-57):
== views and copies of a (3, 4) tensora dims [3, 4] stride [4, 1] offset 0 contiguous true same buffer truea.clone() dims [3, 4] stride [4, 1] offset 0 contiguous true same buffer truea.reshape((2, 6)) dims [2, 6] stride [6, 1] offset 0 contiguous true same buffer truea.unsqueeze(0) dims [1, 3, 4] stride [4, 4, 1] offset 0 contiguous true same buffer truea.t() dims [4, 3] stride [1, 4] offset 0 contiguous false same buffer truea.t().contiguous() dims [4, 3] stride [3, 1] offset 0 contiguous true same buffer falsea.t().reshape(12) dims [12] stride [1] offset 0 contiguous true same buffer falsea.i((1.., 1..3)) dims [2, 2] stride [4, 1] offset 5 contiguous false same buffer truea.i(2) dims [4] stride [1] offset 8 contiguous true same buffer truea.i((.., 2)) dims [3] stride [4] offset 2 contiguous false same buffer truea.broadcast_as((2, 3, 4)) dims [2, 3, 4] stride [0, 4, 1] offset 0 contiguous false same buffer truea.to_dtype(F32) dims [3, 4] stride [4, 1] offset 0 contiguous true same buffer truea.detach() dims [3, 4] stride [4, 1] offset 0 contiguous true same buffer truea.copy() dims [3, 4] stride [4, 1] offset 0 contiguous true same buffer false(&a + 1.0) dims [3, 4] stride [4, 1] offset 0 contiguous true same buffer falseCómo leerlo:
- Contiguo significa que los elementos están en el buffer en orden de filas y sin huecos, strides
[4, 1]para un tensor(3, 4).clone()copia elArc, no los datos, como clonar una referencia en C# o Java. t()intercambia los strides: un paso a lo largo de la nueva primera dimensión salta 1 elemento, un paso a lo largo de la segunda salta 4. Nada se mueve. Lo mismo vale parapermuteytranspose.- Indexar con
icambia el offset y los strides.a.i((1.., 1..3))empieza en el elemento 5, el5.de la fila 1, columna 1. Una fila entera,a.i(2), sigue siendo contigua; una columna,a.i((.., 2)), tiene stride 4 y no lo es. broadcast_asda a la nueva dimensión un stride de 0: cada paso a lo largo de ella lee los mismos elementos. Un broadcast de mil filas no cuesta nada hasta que algo lo copia (ejercicio 2).reshapees una vista cuando el tensor es contiguo, y una copia cuando no lo es (tensor.rs, líneas 2523-2551):a.t().reshape(12)debe poner los elementos en el orden0, 4, 8, 1, …, que el buffer no tiene. Elviewde PyTorch se negaría aquí yreshapecopiaría; Candle solo tienereshape.contiguous()devuelve el mismo tensor cuando ya es contiguo, y copia cuando no lo es (líneas 2466-2477).to_dtypeal tipo que el tensor ya tiene tampoco hace nada (línea 2453).copy()y toda operación aritmética reservan un buffer nuevo. No hay unadd_en el sitio como en PyTorch:a + 1.0siempre reserva memoria.
Una vista y una copia contienen los mismos valores; solo cambia el camino hasta ellos:
== the same values through a view and through a copyt.i(0) [0.0, 4.0, 8.0]tc.i(0) [0.0, 4.0, 8.0]t equals tc everywhere: trueLa diferencia se nota en la velocidad: una operación sobre un tensor con strides recorre el buffer según sus strides, y las mediciones de abajo muestran lo que eso cuesta.
El grafo de operaciones
Sección titulada «El grafo de operaciones»Cada tensor lleva un BackpropOp, la operación que lo produjo con sus entradas, o nada. Una operación solo se registra cuando una de sus entradas está siendo seguida (op.rs, líneas 1100-1116), y un tensor está siendo seguido cuando es una variable o tiene una operación registrada (tensor.rs, líneas 592-594):
pub(crate) fn new1(arg: &Tensor, f: impl Fn(Tensor) -> Op) -> Self { let op = if arg.track_op() { Some(f(arg.clone())) } else { None }; Self(op)}Las líneas 69-101 calculan el mismo tipo de expresión a partir de tensores simples y a partir de una Var, un tensor marcado como variable, y cuentan los nodos detrás del resultado con sorted_nodes, el recorrido que usa backward:
let x = Tensor::new(&[1f32, 2., 3.], &dev)?;let y = x.sqr()?.sum_all()?;let w = Var::new(&[1f32, 2., 3.], &dev)?;let h = w.broadcast_mul(&x)?;let z = h.sqr()?.sum(D::Minus1)?;let zd = z.detach();== the graph of operationsplain tensors: y.track_op() false, nodes behind y 0with a Var: z.track_op() true, nodes behind z 5their dims, from z to w: [[], [1], [3], [3], [3]]w.is_variable() true, h.is_variable() falsez.detach(): track_op() false, nodes behind 0an op after detach: track_op() falseTres consecuencias:
- No hay un modo
no_gradque recordar. PyTorch registra operaciones sobre cualquier tensor conrequires_grad, y el código de inferencia se envuelve entorch.no_grad(). En Candle, un cálculo en el que no interviene ningunaVarno registra nada. El nodo[1]es el broadcast quebroadcast_mulinserta parax; la lección 4 muestra quebackwardtambién da un gradiente ax. - El grafo mantiene vivas sus entradas. Una operación registrada guarda un clon de cada tensor de entrada, un
Arc, así que mientraszviva,h, el broadcast ywsiguen en memoria. En el forward pass de una red, eso es cada activación de cada capa; el entrenamiento las necesita parabackward, la inferencia no. detach()corta el vínculo. Devuelve un tensor sobre el mismo buffer sin operación (líneas 2350-2365), así que los tensores intermedios pueden liberarse en cuanto se suelta el resultado original, y las operaciones sobre él no se registran.
El backend de CPU decide su número de hilos una sola vez, en utils.rs, líneas 313-333:
fn default_num_threads() -> usize { let physical = { #[cfg(target_os = "macos")] { perf_core_count().unwrap_or_else(num_cpus::get_physical) } #[cfg(not(target_os = "macos"))] { num_cpus::get_physical() } }; physical.max(1) // safeguard against bad number}
fn rayon_num_threads() -> usize { std::env::var("RAYON_NUM_THREADS") .ok() .and_then(|s| usize::from_str(&s).ok()) .filter(|nt| nt > &0) .unwrap_or_else(default_num_threads)}- El valor por defecto es el número de núcleos físicos que informa
num_cpus, no los lógicos; en Apple Silicon, solo los núcleos de rendimiento (por verificar, el curso aún no se ha ejecutado en un Mac). RAYON_NUM_THREADSlo sustituye. El producto de matrices lo lee a través deget_num_threadsy se lo pasa agemmcomoParallelism::Rayon(cpu_backend/mod.rs, líneas 1408-1413). Es también la variable que lee el propiorayonpara su pool global.CANDLE_NUM_THREADSdimensiona un pool aparte que usan los productos de matrices de los tensores cuantizados (k_quants.rs, línea 2432), para la lección 8.- Las operaciones elemento a elemento usan un hilo.
add,mul,exporeluson un bucle simple sobre los elementos,binary_mappara las operaciones binarias, diga lo que diga la variable.
num_cpus::get_physical cuenta los núcleos de la máquina, no lo que un contenedor tiene permitido usar; las mediciones de abajo muestran lo que eso provoca con Docker.
Mediciones
Sección titulada «Mediciones»Todas las cifras de abajo salen de l03_bench compilado con cargo build --release, en un Intel Core Ultra 9 285K (24 núcleos: 8 de rendimiento y 16 eficientes, sin hyper-threading), 64 GB de RAM, Windows 11 Pro, Rust 1.94.0, candle-core 0.11.0. Cada cifra es la mediana de varias ejecuciones tras una llamada de calentamiento (líneas 8-19). La máquina ejecutaba otro trabajo al mismo tiempo, así que trata como ruido las diferencias por debajo de un 20 %. check.sh ejecuta el ejemplo sin comparar su salida.
cd code\candlecargo run --release --example l03_bench$env:RAYON_NUM_THREADS = "1"; cargo run --release --example l03_bench; Remove-Item Env:RAYON_NUM_THREADScd code/candlecargo run --release --example l03_benchRAYON_NUM_THREADS=1 cargo run --release --example l03_benchcd code/candlecargo run --release --example l03_benchRAYON_NUM_THREADS=1 cargo run --release --example l03_benchProductos de matrices
Sección titulada «Productos de matrices»Dos matrices n × n de valores en [-1, 1), en milisegundos. Los GFLOPS son miles de millones de operaciones de coma flotante por segundo, contando 2n³ por producto. El bucle triple es el bucle i-k-j de libro de texto en Rust puro sobre los mismos datos f32 (líneas 35-46), el bucle que cualquiera escribiría sin una biblioteca.
| n | hilos | f32 |
f64 |
f16 |
GFLOPS f32 |
bucle triple, f32 |
|---|---|---|---|---|---|---|
| 256 | 24 (por defecto) | 0.21 | 0.25 | 0.17 | 158 | 1.20 |
| 256 | 1 | 0.33 | 0.98 | 0.68 | 103 | 1.27 |
| 512 | 24 | 0.44 | 0.65 | 0.80 | 608 | 11.01 |
| 512 | 1 | 3.67 | 6.95 | 4.01 | 73 | 12.44 |
| 1024 | 24 | 1.75 | 4.19 | 2.97 | 1227 | — |
| 1024 | 8 | 3.66 | 8.51 | 4.05 | 586 | — |
| 1024 | 1 | 20.72 | 38.71 | 20.55 | 104 | — |
- Un hilo de
gemmes de 3 a 4 veces más rápido que el bucle triple. Corta las matrices en bloques que caben en las cachés del procesador y los multiplica con kernels SIMD. - Los hilos compensan sobre todo a partir de
n = 512. Conn = 1024, 24 hilos son 11,8 veces más rápidos que uno; 8 hilos, 5,7 veces. Conn = 256, una matriz ocupa 256 KB def32y los hilos tienen poco que repartir. f64cuesta más o menos el doble quef32: caben la mitad de números en un registro SIMD, y el doble de bytes pasan por las cachés.f16no es más rápido quef32en una CPU. Con un solo hilo son iguales enn = 1024, porquegemm-f16convierte cada bloque af32y lo multiplica con los kernelsf32(pack_lhsen sugemm.rs); con hilos, las conversiones lo hacen más lento. La media precisión ahorra memoria; su aceleración es cosa de las GPU (lección 9).
Operaciones elemento a elemento
Sección titulada «Operaciones elemento a elemento»Matrices 2048 × 2048 de f32, 16 MB cada una, en milisegundos:
| Operación | 24 hilos | 1 hilo |
|---|---|---|
a + b, ambas contiguas |
5.000 | 5.159 |
a.t() + b.t(), ambas con strides |
33.550 | 29.203 |
a.broadcast_add(bias), sesgo de 2048 |
4.131 | 4.845 |
a.t().contiguous(), una copia |
20.415 | 21.200 |
a.exp() |
8.061 | 7.615 |
a.relu() |
5.066 | 5.372 |
a.t(), una vista |
0.000 | 0.000 |
- El número de hilos no cambia nada aquí, como mostraba
binary_mapen la sección anterior: son bucles de un solo hilo. - Un operando con strides cuesta seis veces más.
a.t()tiene strides[1, 2048], así que leer una de sus filas salta 2048 elementos, 8 KB, entre dos números, y tanto la caché del procesador como su vectorización dejan de ayudar.contiguous()paga ese mismo recorrido una vez; después, las operaciones vuelven a los 5 ms. Cuando una operación sobre un tensor traspuesto o permutado está dentro de un bucle, hazlo contiguo antes del bucle. a + byrelutardan lo mismo, así que la mayor parte de esos 5 ms es probablemente reservar y rellenar un buffer nuevo de 16 MB, más que la aritmética (por verificar con un profiler).- Una vista es gratis:
t()construye un layout nuevo y nada más.
Una red pequeña
Sección titulada «Una red pequeña»Tres capas 256 → 256 con una ReLU, un lote de 64, 200 forward passes, en milisegundos. La capa es un Module con un peso y un sesgo (líneas 48-57); los mismos pesos se ejecutan una vez como tensores simples y otra como Var, para que se registre el grafo, en dos rondas alternadas (líneas 121-155):
impl Module for Layer { fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> { xs.matmul(&self.w.t()?)?.broadcast_add(&self.b)?.relu() }}| 24 hilos | 1 hilo | |
|---|---|---|
| ronda 1, tensores simples | 65.94 | 94.29 |
ronda 1, pesos Var, grafo conservado |
45.86 | 50.87 |
| ronda 2, tensores simples | 43.48 | 44.91 |
ronda 2, pesos Var, grafo conservado |
43.08 | 46.28 |
- La primera medición es errónea. Una primera versión del benchmark ejecutaba cada variante una vez, primero los tensores simples, y “descubrió” que el grafo era un 35 % más rápido que no tener grafo. Alternar muestra que la primera ejecución paga el calentamiento del asignador de memoria y del pool de hilos; en la ronda 2 las cuatro cifras están a menos de un 7 % entre sí. Mide cada variante más de una vez, alternando.
- Registrar el grafo no cuesta un tiempo medible: clona unos pocos
Arcpor operación. Su coste es la memoria, como mostró la sección anterior. - Los hilos no aportan nada a este tamaño. Un producto de
64 × 256por256 × 256es demasiado pequeño para repartirlo, igual quen = 256más arriba. El pesow.t()es una vista con strides, perogemmusa los strides directamente y no necesita una copia.
target-cpu=native
Sección titulada «target-cpu=native»La lección 1 mostró que un crate que depende de Candle se compila para un procesador x86-64 de base, sin los kernels AVX2 detrás de with_avx. El mismo benchmark compilado con RUSTFLAGS="-C target-cpu=native", en su propio directorio target para no reemplazar la compilación por defecto (65 segundos con -j 4):
$env:RUSTFLAGS = "-C target-cpu=native"cargo run --release --target-dir target/native --example l03_benchRemove-Item Env:RUSTFLAGSRUSTFLAGS="-C target-cpu=native" cargo run --release --target-dir target/native --example l03_benchRUSTFLAGS="-C target-cpu=native" cargo run --release --target-dir target/native --example l03_bench| 24 hilos | compilación por defecto | target-cpu=native |
|---|---|---|
avx2 kernels compiled in |
false | true |
matmul n = 1024, f32 |
1.85 | 2.14 |
matmul n = 512, bucle triple |
9.69 | 8.82 |
a + b, contiguas |
4.845 | 4.206 |
a.t().contiguous() |
18.782 | 22.400 |
| red, ronda 2, simple | 41.35 | 53.26 |
Ninguna ganancia más allá del ruido, y la ronda de la red incluso salió más lenta. Dos razones, según las fuentes:
gemmya elige sus kernels en tiempo de ejecución. Pregunta al procesador si tiene AVX-512 o FMA conis_x86_feature_detected!(gemm-common,lib.rslíneas 77-83, ygemm.rslínea 1015), así que la compilación por defecto ya multiplica matrices con AVX2 y FMA en este procesador.- Los kernels AVX2 propios de Candle sirven a otras operaciones: los productos escalares de los tensores cuantizados (
k_quants.rs, línea 282), las sumas en reducciones y convoluciones.vec_addparaf32es un bucle simple (cpu/kernels.rs, líneas 88-98) que el compilador vectoriza en cualquier caso.
La opción importa para los modelos cuantizados, que mide la lección 8. También tiene un coste: un binario compilado con target-cpu=native puede fallar con una instrucción ilegal en un procesador más antiguo, así que es una opción para un binario compilado donde se ejecuta, no para uno que distribuyes.
En un contenedor
Sección titulada «En un contenedor»La misma máquina, con Docker Desktop 29.2.1 (kernel Linux 6.18 en WSL 2), en la imagen rust:1.94.0, con el contenedor limitado a cuatro CPU mediante docker run --cpus 4. Esa opción fija una cuota de tiempo; el contenedor sigue viendo los 24 núcleos, y lo mismo nproc y num_cpus. El ejemplo se compiló en el contenedor y se ejecutó tres veces:
| En el contenedor | 24 hilos (por defecto) | RAYON_NUM_THREADS=4 |
RAYON_NUM_THREADS=1 |
|---|---|---|---|
matmul n = 1024, f32 |
4.36 | 4.15 | 14.54 |
matmul n = 256, f32 |
0.51 | 0.16 | 0.22 |
| red, ronda 2, simple | 168.16 | 52.74 | 43.80 |
red, ronda 2, Var |
167.26 | 54.70 | 44.63 |
a + b, contiguas |
2.776 | 2.545 | 2.467 |
Con el valor por defecto, Candle arranca 24 hilos sobre una cuota de 4 CPU: se pasan el tiempo esperando al planificador, y la red pequeña va cuatro veces más lenta que con un solo hilo. Poner RAYON_NUM_THREADS al número de CPU que recibe el contenedor devuelve el producto grande a su mejor tiempo y la red a un tiempo cercano al de un solo hilo. Cuando despliegues un programa de Candle en un contenedor con un límite de CPU (lección 10), define RAYON_NUM_THREADS explícitamente.
Las operaciones elemento a elemento contiguas tardan la mitad que en Windows, en el mismo procesador. Encaja con la hipótesis de arriba de que las domina la reserva del resultado, ya que los dos sistemas comprometen la memoria nueva de forma distinta (por verificar).
Puntos clave
Sección titulada «Puntos clave»from_vecse queda con tu buffer;from_sliceyTensor::newlo copian.clone,reshapede un tensor contiguo,t,permute,i,broadcast_as,detachyto_dtypeal mismo tipo son vistas; la aritmética,copyycontiguousde un tensor con strides reservan memoria.- Un broadcast tiene stride 0 y no cuesta nada hasta que se copia.
- Las operaciones solo se registran cuando interviene una
Var. El grafo no cuesta un tiempo medible, pero mantiene vivo cada tensor intermedio hasta que el resultado se suelta o se desacopla. - El backend de CPU usa tantos hilos como núcleos físicos, solo para los productos de matrices;
RAYON_NUM_THREADSlo cambia. Las operaciones elemento a elemento usan un solo hilo. - En esta máquina, los hilos compensan en productos de matrices a partir de unos
512 × 512, y en un contenedor limitado a 4 CPU los 24 hilos por defecto hacen una red pequeña cuatro veces más lenta.f16no es más rápido quef32, los operandos con strides cuestan seis veces más, ytarget-cpu=nativeno cambia nada para estas operaciones. - Mide dos veces, alternando: la primera ejecución paga el calentamiento.
Ejercicios
Sección titulada «Ejercicios»- Sin ejecutarlo, predice para cada una de estas operaciones sobre
a = arange(0, 24).reshape((2, 3, 4))si el resultado es contiguo y si comparte el buffer dea:a.permute((2, 0, 1)), y luego.flatten_all()sobre él;a.flatten_all();a.i((.., 1, ..)), y luego.contiguous()sobre él;a.i((1, ..)).contiguous();a.squeeze(0);a.transpose(1, 1).
Solución
Las líneas 27-49 imprimen la respuesta:
== exercise 1: view or copy?a.permute((2, 0, 1)) contiguous false same buffer truea.permute((2, 0, 1)).flatten_all() contiguous true same buffer falsea.flatten_all() contiguous true same buffer truea.i((.., 1, ..)) contiguous false same buffer truea.i((.., 1, ..)).contiguous() contiguous true same buffer falsea.i((1, ..)).contiguous() contiguous true same buffer truea.squeeze(0) contiguous true same buffer truea.transpose(1, 1) contiguous true same buffer trueflatten_alles unreshape, así que copia un tensor permutado y no uno contiguo.a.i((1, ..))es el segundo bloque de 12 elementos, contiguo con un offset de 12, así quecontiguous()lo devuelve tal cual. La fila central de cada bloque,a.i((.., 1, ..)), tiene huecos y hay que copiarla.a.squeeze(0)es la trampa: la dimensión 0 tiene tamaño 2, así que no hay nada que quitar, y Candle devuelveasin cambios en lugar de un error, como hace PyTorch (lección 2).transpose(1, 1)intercambia una dimensión consigo misma.
- Un sesgo de 1000 ceros se expande con broadcast a
(1000, 1000), se hace contiguo, y luego solo se conserva su primera fila. ¿Cuántos bytes ocupa cada paso? Usastorage_and_layoutpara leer la longitud del buffer detrás de un tensor. ¿Basta concopy()de la fila para liberar el resto?
Solución
let bias = Tensor::zeros(1000, DType::F32, &dev)?;let wide = bias.broadcast_as((1000, 1000))?;let copy = wide.contiguous()?;let row = copy.i(0)?;let small = row.copy()?;let compact = row.force_contiguous()?;== exercise 2: the memory behind a broadcastwide: 1000000 elements, buffer 4000 bytescopy: 1000000 elements, buffer 4000000 bytescopy.i(0): 1000 elements, buffer 4000000 bytescopy.i(0).copy(): 1000 elements, buffer 4000000 bytescopy.i(0).force_contiguous(): 1000 elements, buffer 4000 bytesEl broadcast ocupa los 4 KB del sesgo para un millón de elementos. contiguous() materializa 4 MB. La fila es una vista sobre esos 4 MB, lo cual es lo esperado. La sorpresa es copy(): clona todo el almacenamiento y conserva el layout de la fila (tensor.rs, líneas 2332-2344, que llama a try_clone, cpu_backend/mod.rs, líneas 2991-2993), así que una copia de 1000 elementos sigue ocupando 4 MB. force_contiguous copia solo los elementos del layout. Para quedarte con un trozo pequeño de un tensor grande y dejar ir el resto, usa force_contiguous, no copy. El diario lo recoge.
Fuentes
Sección titulada «Fuentes»- Candle en
31f35b1:tensor.rs,op.rs,utils.rs,cpu_backend/mod.rs,cpu/kernels.rs,.cargo/config.toml gemm0.19.0 y las fuentes degemm-commonygemm-f16num_cpus::get_physicaly elRAYON_NUM_THREADSde rayon- PyTorch: vistas de tensores
- The rustc book:
target-cpu