3. Calcul et performance sur CPU
Sections
Réglages de lecture
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
La leçon 2 a utilisé des tenseurs sans se demander ce qu’ils coûtent. Cette leçon regarde dessous : quand deux tenseurs partagent la mémoire, quand Candle copie, à quoi s’accroche le graphe des opérations, et comment le backend CPU dépense ses threads. Puis elle mesure, sur une machine, avec les versions nommées, parce que des affirmations de performance sans machine sont des opinions.
Le code est examples/l03_views.rs, examples/l03_bench.rs et examples/l03_exercises.rs.
Vues et copies
Section intitulée « Vues et copies »Un Tensor est un tampon partagé plus un Layout : une forme, un stride par dimension (combien d’éléments du tampon un pas le long de cette dimension saute) et un offset (où se trouve le premier élément). Deux tenseurs peuvent partager le tampon avec des layouts différents. L’exemple dit si c’est le cas en comparant l’adresse du premier élément du tampon derrière chaque tenseur (lignes 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"), }}Créer un tenseur à partir d’un Vec prend le Vec sans le copier, et le créer à partir d’une slice copie (lignes 30-40) :
== from_vec keeps the Vec's buffersame buffer as the Vec: truefrom_slice, same buffer as the slice: falsefrom_vec est donc la façon de confier à Candle un grand tampon que tu as construit toi-même ; Tensor::new(&[...]) et from_slice copient toujours. Viennent ensuite quinze opérations sur un tenseur (3, 4) a, qui contient 0 à 11 (lignes 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 falseComment le lire :
- Contigu veut dire que les éléments sont rangés dans le tampon dans l’ordre des lignes, sans trou, avec les strides
[4, 1]pour un tenseur(3, 4).clone()copie l’Arc, pas les données, comme cloner une référence en C# ou en Java. t()échange les strides : un pas le long de la nouvelle première dimension saute 1 élément, un pas le long de la seconde en saute 4. Rien ne bouge. Il en va de même pourpermuteettranspose.- L’indexation avec
ichange l’offset et les strides.a.i((1.., 1..3))commence à l’élément 5, le5.de la ligne 1, colonne 1. Une ligne entière,a.i(2), reste contiguë ; une colonne,a.i((.., 2)), a un stride de 4 et ne l’est pas. broadcast_asdonne à la nouvelle dimension un stride de 0 : chaque pas le long d’elle relit les mêmes éléments. Un broadcast sur mille lignes ne coûte rien tant que rien ne le copie (exercice 2).reshapeest une vue quand le tenseur est contigu, et une copie quand il ne l’est pas (tensor.rs, lignes 2523-2551) :a.t().reshape(12)doit ranger les éléments dans l’ordre0, 4, 8, 1, …, que le tampon n’a pas. Leviewde PyTorch refuserait ici etreshapecopierait ; Candle n’a quereshape.contiguous()renvoie le même tenseur quand il est déjà contigu, et copie quand il ne l’est pas (lignes 2466-2477).to_dtypevers le type que le tenseur a déjà ne fait rien non plus (ligne 2453).copy()et chaque opération arithmétique allouent un nouveau tampon. Il n’y a pas deadd_en place comme dans PyTorch :a + 1.0alloue toujours.
Une vue et une copie contiennent les mêmes valeurs ; seul le chemin pour y accéder diffère :
== 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 différence se voit sur la vitesse : une opération sur un tenseur à strides parcourt le tampon selon ses strides, et les mesures ci-dessous montrent ce que cela coûte.
Le graphe des opérations
Section intitulée « Le graphe des opérations »Chaque tenseur porte un BackpropOp, l’opération qui l’a produit avec ses entrées, ou rien. Une opération ne s’enregistre que si l’une de ses entrées est suivie (op.rs, lignes 1100-1116), et un tenseur est suivi quand c’est une variable ou qu’il a une opération enregistrée (tensor.rs, lignes 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)}Lignes 69-101 calculent le même genre d’expression à partir de tenseurs simples et d’une Var, un tenseur marqué comme variable, et comptent les nœuds derrière le résultat avec sorted_nodes, le parcours qu’utilise 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() falseTrois conséquences :
- Il n’y a pas de mode
no_gradà ne pas oublier. PyTorch enregistre les opérations sur tout tenseur avecrequires_grad, et le code d’inférence s’enveloppe danstorch.no_grad(). Dans Candle, un calcul qui n’implique aucuneVarn’enregistre rien. Le nœud[1]est le broadcast quebroadcast_mulinsère pourx; la leçon 4 montre quebackwarddonne aussi un gradient àx. - Le graphe garde ses entrées en vie. Une opération enregistrée garde un clone de chaque tenseur d’entrée, un
Arc, donc tant quezvit,h, le broadcast etwrestent en mémoire. Pour la passe avant d’un réseau, ce sont toutes les activations de toutes les couches ; l’entraînement en a besoin pourbackward, l’inférence non. detach()coupe le lien. Il renvoie un tenseur sur le même tampon, sans opération (lignes 2350-2365), donc les tenseurs intermédiaires peuvent être libérés une fois le résultat d’origine abandonné, et les opérations sur lui ne sont pas enregistrées.
Le backend CPU décide une fois pour toutes de son nombre de threads, dans utils.rs, lignes 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)}- Par défaut, c’est le nombre de cœurs physiques rapporté par
num_cpus, pas logiques ; sur Apple Silicon, seulement les cœurs performance (à vérifier, le cours n’a pas encore tourné sur Mac). RAYON_NUM_THREADSle remplace. Le produit matriciel le lit viaget_num_threadset le passe àgemmsous forme deParallelism::Rayon(cpu_backend/mod.rs, lignes 1408-1413). C’est aussi la variable querayonlit lui-même pour son pool global.CANDLE_NUM_THREADSdimensionne un pool séparé, utilisé par les produits matriciels des tenseurs quantifiés (k_quants.rs, ligne 2432), pour la leçon 8.- Les opérations élément par élément utilisent un seul thread.
add,mul,expourelusont une simple boucle sur les éléments,binary_mappour les opérations binaires, quoi que dise la variable.
num_cpus::get_physical compte les cœurs de la machine, pas ce qu’un conteneur a le droit d’utiliser ; les mesures ci-dessous montrent ce que cela donne sous Docker.
Tous les nombres ci-dessous viennent de l03_bench compilé avec cargo build --release, sur un Intel Core Ultra 9 285K (24 cœurs : 8 performance et 16 efficaces, sans hyper-threading), 64 Go de RAM, Windows 11 Pro, Rust 1.94.0, candle-core 0.11.0. Chaque chiffre est la médiane de plusieurs exécutions après un appel d’échauffement (lignes 8-19). La machine faisait tourner d’autres travaux en même temps, donc considère les écarts de moins de 20 % environ comme du bruit. check.sh exécute l’exemple sans comparer sa sortie.
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_benchProduits matriciels
Section intitulée « Produits matriciels »Deux matrices n × n de valeurs dans [-1, 1), en millisecondes. Les GFLOPS sont des milliards d’opérations en virgule flottante par seconde, en comptant 2n³ pour un produit. La triple boucle est la boucle i-k-j des manuels, en Rust simple, sur les mêmes données f32 (lignes 35-46), la boucle que n’importe qui écrirait sans bibliothèque.
| n | threads | f32 |
f64 |
f16 |
GFLOPS en f32 |
triple boucle, f32 |
|---|---|---|---|---|---|---|
| 256 | 24 (défaut) | 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 seul thread de
gemmest 3 à 4 fois plus rapide que la triple boucle. Il découpe les matrices en blocs qui tiennent dans les caches du processeur et les multiplie avec des noyaux SIMD. - Les threads paient surtout à partir de
n = 512. Àn = 1024, 24 threads sont 11,8 fois plus rapides qu’un seul ; 8 threads, 5,7 fois. Àn = 256, une matrice fait 256 Ko def32et les threads ont peu à se partager. f64coûte environ deux foisf32: deux fois moins de nombres tiennent dans un registre SIMD, et deux fois plus d’octets passent par les caches.f16n’est pas plus rapide quef32sur un CPU. Sur un thread, ils sont égaux àn = 1024, parce quegemm-f16convertit chaque bloc enf32et le multiplie avec les noyauxf32(pack_lhsdans songemm.rs) ; avec des threads, les conversions le rendent plus lent. La demi-précision économise de la mémoire ; son gain de vitesse appartient aux GPU (leçon 9).
Opérations élément par élément
Section intitulée « Opérations élément par élément »Matrices 2048 × 2048 de f32, 16 Mo chacune, en millisecondes :
| Opération | 24 threads | 1 thread |
|---|---|---|
a + b, les deux contiguës |
5.000 | 5.159 |
a.t() + b.t(), les deux à strides |
33.550 | 29.203 |
a.broadcast_add(bias), biais de 2048 |
4.131 | 4.845 |
a.t().contiguous(), une copie |
20.415 | 21.200 |
a.exp() |
8.061 | 7.615 |
a.relu() |
5.066 | 5.372 |
a.t(), une vue |
0.000 | 0.000 |
- Le nombre de threads ne change rien ici, comme
binary_maple montrait dans la section précédente : ce sont des boucles sur un seul thread. - Un opérande à strides coûte six fois plus.
a.t()a les strides[1, 2048], donc lire une de ses lignes saute 2048 éléments, 8 Ko, entre deux nombres, et le cache du processeur comme sa vectorisation cessent d’aider.contiguous()paie le même parcours une fois ; ensuite, les opérations reviennent à 5 ms. Quand une opération sur un tenseur transposé ou permuté se trouve dans une boucle, rends-le contigu avant la boucle. a + betreluprennent le même temps, donc l’essentiel de ces 5 ms est probablement l’allocation et le remplissage d’un tampon neuf de 16 Mo plutôt que l’arithmétique (à vérifier avec un profileur).- Une vue est gratuite :
t()construit un nouveau layout et rien d’autre.
Un petit réseau
Section intitulée « Un petit réseau »Trois couches 256 → 256 avec une ReLU, un lot de 64, 200 passes avant, en millisecondes. La couche est un Module avec un poids et un biais (lignes 48-57) ; les mêmes poids sont exécutés une fois comme tenseurs simples et une fois comme Var, pour que le graphe soit enregistré, en deux tours alternés (lignes 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 threads | 1 thread | |
|---|---|---|
| tour 1, tenseurs simples | 65.94 | 94.29 |
tour 1, poids en Var, graphe gardé |
45.86 | 50.87 |
| tour 2, tenseurs simples | 43.48 | 44.91 |
tour 2, poids en Var, graphe gardé |
43.08 | 46.28 |
- La première mesure est fausse. Une première version du benchmark exécutait chaque variante une fois, les tenseurs simples d’abord, et « trouvait » le graphe 35 % plus rapide que l’absence de graphe. L’alternance montre que la première exécution paie l’échauffement de l’allocateur et du pool de threads ; au tour 2, les quatre chiffres sont à 7 % les uns des autres. Mesure chaque variante plus d’une fois, en alternance.
- Enregistrer le graphe ne coûte aucun temps mesurable : cela clone quelques
Arcpar opération. Son coût est la mémoire, comme l’a montré la section précédente. - Les threads n’apportent rien à cette taille. Un produit
64 × 256par256 × 256est trop petit pour être partagé, commen = 256plus haut. Le poidsw.t()est une vue à strides, maisgemmprend directement les strides et n’a pas besoin de copie.
target-cpu=native
Section intitulée « target-cpu=native »La leçon 1 a montré qu’une crate qui dépend de Candle est compilée pour un processeur x86-64 de base, sans les noyaux AVX2 derrière with_avx. Le même benchmark compilé avec RUSTFLAGS="-C target-cpu=native", dans son propre répertoire cible pour ne pas remplacer le build par défaut (65 secondes avec -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 threads | build par défaut | target-cpu=native |
|---|---|---|
avx2 kernels compiled in |
false | true |
matmul n = 1024, f32 |
1.85 | 2.14 |
matmul n = 512, triple boucle |
9.69 | 8.82 |
a + b, contiguës |
4.845 | 4.206 |
a.t().contiguous() |
18.782 | 22.400 |
| réseau, tour 2, simple | 41.35 | 53.26 |
Aucun gain au-delà du bruit, et le tour du réseau est même sorti plus lent. Deux raisons, tirées des sources :
gemmchoisit déjà ses noyaux à l’exécution. Il demande au processeur s’il a AVX-512 ou FMA avecis_x86_feature_detected!(gemm-common,lib.rslignes 77-83, etgemm.rsligne 1015), donc le build par défaut multiplie déjà les matrices avec AVX2 et FMA sur ce processeur.- Les noyaux AVX2 de Candle servent à d’autres opérations : les produits scalaires des tenseurs quantifiés (
k_quants.rs, ligne 282), les sommes dans les réductions et les convolutions.vec_addpourf32est une simple boucle (cpu/kernels.rs, lignes 88-98) que le compilateur vectorise dans les deux cas.
Le drapeau compte pour les modèles quantifiés, que la leçon 8 mesure. Il a aussi un coût : un binaire compilé avec target-cpu=native peut planter sur une instruction illégale sur un processeur plus ancien, c’est donc un drapeau pour un binaire compilé là où il tourne, pas pour un binaire que tu distribues.
Dans un conteneur
Section intitulée « Dans un conteneur »La même machine, sous Docker Desktop 29.2.1 (noyau Linux 6.18 dans WSL 2), dans l’image rust:1.94.0, avec le conteneur limité à quatre CPU par docker run --cpus 4. Cette option fixe un quota de temps ; le conteneur voit toujours les 24 cœurs, tout comme nproc et num_cpus. L’exemple a été compilé dans le conteneur et exécuté trois fois :
| Dans le conteneur | 24 threads (défaut) | 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 |
| réseau, tour 2, simple | 168.16 | 52.74 | 43.80 |
réseau, tour 2, Var |
167.26 | 54.70 | 44.63 |
a + b, contiguës |
2.776 | 2.545 | 2.467 |
Par défaut, Candle démarre 24 threads sur un quota de 4 CPU : ils passent leur temps à attendre l’ordonnanceur, et le petit réseau tourne quatre fois plus lentement qu’avec un seul thread. Régler RAYON_NUM_THREADS sur le nombre de CPU accordés au conteneur ramène le grand produit à son meilleur et le réseau près du temps sur un seul thread. Quand tu déploies un programme Candle dans un conteneur avec une limite de CPU (leçon 10), règle RAYON_NUM_THREADS explicitement.
Les opérations élément par élément contiguës prennent moitié moins de temps que sous Windows, sur le même processeur. Cela concorde avec l’hypothèse ci-dessus selon laquelle l’allocation du résultat les domine, puisque les deux systèmes engagent la mémoire neuve différemment (à vérifier).
À retenir
Section intitulée « À retenir »from_vecprend ton tampon ;from_sliceetTensor::newle copient.clone,reshaped’un tenseur contigu,t,permute,i,broadcast_as,detachetto_dtypevers le même type sont des vues ; l’arithmétique,copyetcontiguousd’un tenseur à strides allouent.- Un broadcast a un stride de 0 et ne coûte rien tant qu’il n’est pas copié.
- Les opérations ne sont enregistrées que si une
Varest en jeu. Le graphe ne coûte aucun temps mesurable, mais garde chaque tenseur intermédiaire en vie jusqu’à ce que le résultat soit abandonné ou détaché. - Le backend CPU utilise autant de threads que de cœurs physiques, pour les produits matriciels seulement ;
RAYON_NUM_THREADSle change. Les opérations élément par élément tournent sur un seul thread. - Sur cette machine, les threads paient pour les produits matriciels à partir d’environ
512 × 512, et dans un conteneur limité à 4 CPU les 24 threads par défaut rendent un petit réseau quatre fois plus lent.f16n’est pas plus rapide quef32, les opérandes à strides coûtent six fois plus, ettarget-cpu=nativene change rien pour ces opérations. - Mesure deux fois, en alternance : la première exécution paie l’échauffement.
Exercices
Section intitulée « Exercices »- Sans l’exécuter, prédis pour chacune de ces opérations sur
a = arange(0, 24).reshape((2, 3, 4))si le résultat est contigu et s’il partage le tampon dea:a.permute((2, 0, 1)), puis.flatten_all()dessus ;a.flatten_all();a.i((.., 1, ..)), puis.contiguous()dessus ;a.i((1, ..)).contiguous();a.squeeze(0);a.transpose(1, 1).
Solution
Lignes 27-49 affichent la réponse :
== 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_allest unreshape, donc il copie un tenseur permuté et pas un tenseur contigu.a.i((1, ..))est le second bloc de 12 éléments, contigu avec un offset de 12, donccontiguous()le renvoie tel quel. La ligne du milieu de chaque bloc,a.i((.., 1, ..)), a des trous et doit être copiée.a.squeeze(0)est le piège : la dimension 0 a une taille 2, il n’y a donc rien à retirer, et Candle renvoieainchangé au lieu d’une erreur, comme le fait PyTorch (leçon 2).transpose(1, 1)échange une dimension avec elle-même.
- Un biais de 1000 zéros est étendu par broadcasting à
(1000, 1000), rendu contigu, puis seule sa première ligne est gardée. Combien d’octets chaque étape tient-elle ? Utilisestorage_and_layoutpour lire la longueur du tampon derrière un tenseur. Uncopy()de la ligne suffit-il à libérer le reste ?
Solution
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 bytesLe broadcast tient les 4 Ko du biais pour un million d’éléments. contiguous() matérialise 4 Mo. La ligne est une vue sur ces 4 Mo, ce qui est attendu. La surprise, c’est copy() : il clone tout le stockage et garde le layout de la ligne (tensor.rs, lignes 2332-2344, qui appelle try_clone, cpu_backend/mod.rs, lignes 2991-2993), donc une copie de 1000 éléments tient toujours 4 Mo. force_contiguous ne copie que les éléments du layout. Pour garder un petit morceau d’un grand tenseur et laisser partir le reste, utilise force_contiguous, pas copy. Le journal le recense.
- Candle à
31f35b1:tensor.rs,op.rs,utils.rs,cpu_backend/mod.rs,cpu/kernels.rs,.cargo/config.toml gemm0.19.0 et les sources degemm-commonetgemm-f16num_cpus::get_physicalet leRAYON_NUM_THREADSde rayon- PyTorch : les vues de tenseurs
- Le livre de rustc :
target-cpu