perf(preview): supprimer le travail redondant au changement de segment, avec la sonde qui l'a trouvé - #207
Merged
Merged
Conversation
…ur pour rien
`Decoder::seek_to` faisait systématiquement `av_seek_frame(BACKWARD)` +
`avcodec_flush_buffers`, puis redécodait depuis l'image clé précédente — jusqu'à
`gop_size` frames (60 sur nos captures), et deux fois puisque l'écran et la webcam
ont chacun leur décodeur. Mesuré à ~50 ms par pas de scrub, sur le chemin exact de
l'édition en pause.
Or le cas dominant n'est pas un saut : c'est « la frame suivante » (scrub,
pas-à-pas) ou « la même frame » (un paramètre a changé, la scène est recomposée au
même instant). Deux chemins rapides couvrent ça :
1. la frame courante est déjà celle demandée (à moins d'une demi-durée de frame)
→ on la rend, zéro décodage ;
2. la cible est devant et à moins de 0,5 s → on déroule depuis la position
courante, sans jeter l'état.
Recul ou saut lointain : seek complet, inchangé. Le seuil de 0,5 s est le demi-GOP
de nos captures, au-delà duquel repartir d'une clé redevient moins cher.
Le critère d'arrêt de `decode_forward_to` est la MÊME expression que celui du seek
complet, donc les deux chemins rendent la même frame : optimisation, pas changement
de comportement. `cur_pts` est nécessaire parce qu'un `AVFrame` fraîchement alloué a
un `best_effort_timestamp` indéterminé — sans lui, impossible de savoir si l'état
courant est exploitable ; il est remis à `None` à chaque flush.
Première optimisation appliquée seule sur une branche partie de release/v1.8.0,
après qu'une pile de sept changements simultanés se soit révélée impossible à
attribuer. Vérifié : 101 tests du crate. NON vérifié : le ressenti au scrub, et le
comportement au franchissement de clip.
…'arrêt
Le tick rAF de `VirtualPreview` publiait `updateVirtualTime(...)` dérivé de
`v.currentTime` en permanence, y compris quand la lecture est arrêtée. Pendant la
lecture c'est la bonne source — le média avance, la tête le suit. À l'arrêt c'est
l'inverse : l'utilisateur possède la tête de lecture et le `<video>` doit la SUIVRE.
Deux symptômes en découlaient :
- un scrub était écrasé à chaque frame par la position d'un élément encore en
train de chercher ;
- près d'une frontière de clips, `locateSourcePosition` ne résolvait pas, et le
repli `seekToVirtualTimeRef(nextClip.timelineStartSec)` renvoyait la tête au
DÉBUT du clip voisin — le tressaillement au passage d'un clip à l'autre, visible
dans les deux sens.
`clockRef` et `setSourceTimeSec` continuent d'être publiés : la webcam et le calque
curseur ont besoin du temps source même à l'arrêt. Seule la position de la TIMELINE
cesse d'être dictée par le média.
Deuxième optimisation appliquée seule. Correctif déjà constaté efficace sur le saut
du curseur lors d'un essai précédent, mais il y était noyé parmi sept changements
simultanés, donc non attribuable ; isolé ici pour être jugé pour lui-même.
À surveiller : ce tick réécrivait aussi la position 60 fois par seconde, ce qui
faisait re-déclencher la bascule de clip par accident jusqu'à ce qu'elle prenne.
Si le passage d'un clip à l'autre se dégrade, c'est que cette réparation par
tremblement masquait un défaut du verrou de bascule dans `NativeCompositorOverlay`
— à traiter alors comme un troisième correctif distinct.
Vérifié : tsc, 66/66 tests src/components/ai-edition.
`latest_frame_since` clonait le buffer RGBA à chaque livraison — un memcpy `O(w·h)` mesuré à 1,5 ms en 1280×720 et 3,4 ms en 1920×1080, payé sur le THREAD PRINCIPAL de Node, celui-là même qui doit rester libre pour que React peigne la tête de lecture. Le buffer est désormais EMPORTÉ (`take`). Le thread de rendu le remplace à chaque frame composée et le consommateur garde ses pixels peints sur le canvas : personne ne relit jamais la même génération, donc la copie ne servait à rien. La génération passe dans un `AtomicU64` séparé : elle était dérivée du slot (`slot.map(|g| g+1).unwrap_or(1)`), or vider le slot la ferait repartir à 1 — le consommateur recevrait des générations déjà peintes et boucherait. Séquence identique à l'ancienne tant que le slot n'est pas vidé. Contrepartie assumée : une relecture forcée (`sinceGen = 0`) après la première ne retrouve rien tant qu'une nouvelle frame n'est pas composée. Sans conséquence ici — le seul consommateur ne l'utilise qu'au montage, et un redimensionnement provoque de toute façon une recomposition. Troisième optimisation appliquée seule. Invisible par construction : elle libère du temps de thread principal, elle ne change aucun pixel. Le critère de validation est donc « rien ne se dégrade », pas « on voit mieux ». Vérifié : 101 tests du crate.
Diagnostic activable à la demande (`window.__uiProbe.start()` depuis la console du
renderer), inerte tant qu'on ne l'appelle pas.
Elle existe parce que les mesures précédentes ne pouvaient pas trancher :
- une moyenne sur une fenêtre dont on ignore le taux d'activité ne dit rien. 45 s
dont 15 s de scrub donnent un chiffre dilué par 30 s d'inactivité, qu'on attribue
quand même au scrub. Ici chaque intervalle rAF est rangé dans l'état où il a été
mesuré : repos / preview / scrub / scrub+preview ;
- une UI rugueuse n'a pas une moyenne haute, elle a des retards épisodiques. À
60 Hz, un p50 à 16,7 ms coexiste très bien avec 5 % de frames à 50 ms, et ce sont
ces 5 % qu'on ressent. D'où le comptage des frames au-delà de 25 et 40 ms plutôt
qu'une moyenne ;
- une fenêtre cachée est throttlée par Chromium, donc la sonde refuse de démarrer
dans ce cas plutôt que de produire des chiffres inadmissibles.
Un `PerformanceObserver` sur `longtask` nomme en plus les blocages > 50 ms, pour
distinguer « une grosse opération synchrone » d'« une accumulation de travaux
moyens » — les deux n'appellent pas le même correctif.
Premier verdict obtenu avec elle : repos 0 % de frames > 25 ms, preview seule 0,6 %,
scrub seul 8,8 %, scrub+preview 23,8 % (p90 à 50 ms), et quasiment aucune tâche
longue. La preview seule ne coûte rien ; c'est la manipulation, et surtout la
manipulation PENDANT que la preview se met à jour, qui casse le budget de frame — par
accumulation de travaux moyens, pas par un blocage unique.
…is pendant un scrub `seekToClientX` appelait `setScrubbingTimeSec(...)` à CHAQUE `pointermove`. Une souris émet 125 à 1000 événements par seconde ; à chacun, cet état local re-rendait `V4Timeline` en entier — clips, waveforms, régions, lane pills — pour déplacer une tête de lecture que la ligne juste au-dessus venait DÉJÀ d'écrire directement dans le DOM. Ce rendu React ne sert qu'aux consommateurs de l'override (timecode, overlay), qui n'ont aucune raison d'être rafraîchis plus vite qu'une frame. Il rejoint donc le rAF qui throttlait déjà l'écriture au store. L'écriture DOM directe reste, elle, à chaque événement : la tête continue de coller au curseur. Mesuré avant ce changement avec `uiFrameProbe`, segmenté par état : repos >25ms = 0,0 % preview seule >25ms = 0,6 % scrub seul >25ms = 8,8 % p99 = 50 ms scrub+preview >25ms = 23,8 % p90 = 50 ms, >40ms = 16,9 % La preview seule ne coûte rien : c'est la manipulation qui casse le budget de frame. Ce commit vise les 8,8 % du scrub seul. Le reste — l'écart entre 8,8 % et 23,8 % — vient de la peinture de la preview sur le thread principal (`ImageData` + `createImageBitmap` + `drawImage` de ~3 Mo) et sera traité séparément. Vérifié : tsc, 14/14 tests v4. La sonde donne le critère chiffré pour juger l'effet — relancer `__uiProbe.start()` et comparer la ligne `scrub`.
…ent souris pendant un scrub" This reverts commit 4fe4a85.
…issement de clip Une comparaison a déjà été faussée par cette variable cachée : un run où le scrub restait dans un seul clip a donné 8,8 % de frames au-delà de 25 ms, un autre où il franchissait une frontière en a donné 21 % — et l'écart a été attribué à un changement de code qui n'y était pour rien. Le correctif suspecté a été appliqué puis reverté sur la foi de cette fausse lecture. Chaque état porte désormais le nombre de bascules déjà vues (`scrub@0`, `scrub@1`…), de sorte que « avant » et « après » ne peuvent plus être moyennés ensemble. La séparation est faite par la sonde, pas par la discipline de celui qui teste. Le rapport affiche aussi le nombre d'éléments `<video>` vivants. Le `<video>` de la preview est keyé sur l'id du clip, donc React le remonte à chaque bascule ; si ce compte grimpe, des éléments média orphelins survivent avec leur décodeur, ce qui expliquerait une dégradation qui PERSISTE après un franchissement au lieu d'être un coût transitoire. Hypothèse à confirmer ou infirmer par ce chiffre, pas à corriger d'avance. Vérifié : tsc, 66/66 tests src/components/ai-edition.
…ange L'app raisonne en SEGMENTS : `resolveVisibleClips` découpe les clips aux trims, et deux segments consécutifs d'un même clip pointent sur le MÊME fichier source — seule la fenêtre temporelle diffère. Or chaque changement de segment déclenchait un `set_active_clip` complet, c'est-à-dire deux `Decoder::open` + `avformat_find_stream_info` + init D3D11VA, pour des fichiers déjà ouverts. Mesuré avec `uiFrameProbe` sur un scrub traversant deux clips, l'utilisateur ayant fait 2 changements de clip voulus : 31 bascules au total, 21 à moins de 500 ms de la précédente, écart médian 261 ms, 13 aller-retours A→B→A séquence : seg1→seg2 seg2→seg3 seg3→seg2 seg2→seg1 seg1→seg2 seg2→seg3 … Ce sont des `seg`, pas des clips. La comparaison porte donc désormais sur les chemins réels (écran, webcam, décalage caméra) : identiques → `Player::seek_active`, qui repositionne les décodeurs déjà ouverts ; différents → `set_active_clip` inchangé. Ce que ça n'est pas : un anti-rebond. On ne retarde ni n'ignore aucune demande de l'app — on cesse seulement de refaire un travail d'ouverture quand rien n'a changé côté média. La position demandée est honorée à chaque fois. Trouvé après avoir écarté deux hypothèses par la mesure : les `<video>` orphelins (le compte reste à 2 quel que soit le nombre de bascules) et le re-rendu React par `pointermove` (throttle appliqué puis reverté, l'écart venait d'une variable cachée — le franchissement de clip — et non du code). Vérifié : 101 tests du crate. NON vérifié : l'effet ressenti, à mesurer avec la sonde (`scrub@N` et le compte de bascules) avant de conclure.
`Compositor::clear_srv_cache` existait, documentée « à appeler après la fermeture d'un
jeu de décodeurs pour ne pas retenir indéfiniment des textures de pool », et n'avait
AUCUN appelant.
Le cache est keyé sur `(adresse de la texture, tranche d'array)`. Chaque bascule de clip
ferme un jeu de décodeurs et laissait ses entrées derrière, avec deux conséquences dont
la seconde n'est pas une fuite mais une faute :
1. le cache grandit sans borne et retient les textures via les `ID3D11ShaderResourceView`
clonés qu'il conserve ;
2. un décodeur neuf peut allouer une texture à une adresse déjà vue. La clé entre alors
en collision et `nv12_srvs` rend le SRV PÉRIMÉ — c'est-à-dire l'image du clip
précédent, sur un chemin où rien ne signale l'erreur.
Le point 2 est un candidat sérieux pour le « mauvais clip affiché » observé en usage, que
j'avais d'abord cherché du côté du verrou de bascule React.
Mesuré avec `uiFrameProbe` : le REPOS lui-même se dégrade avec le nombre de bascules —
0,7 % de frames au-delà de 25 ms à `repos@0`, 7,3 % à `repos@33`, sans aucune interaction.
Une dégradation qui persiste à l'arrêt désigne une accumulation, pas un coût de transition.
Vidage aux deux endroits où les décodeurs sont réellement remplacés : le traitement
d'`active_clip_request` (uniquement quand le média change — un simple changement de segment
ne ferme plus rien depuis le commit précédent) et `advance_to_next_scene_clip` pendant la
lecture libre.
Vérifié : 101 tests du crate. NON vérifié : que `repos@N` cesse de dériver — c'est la
mesure qui tranche, à refaire avec la sonde.
…relire le curseur pour rien Deux défauts introduits par le commit précédent, dont un fatal. FATAL — `seek_active` marquait la frame comme utilisable sans vérifier les DEUX seeks. Ce chemin hérite de décodeurs déjà ouverts, donc d'un état : fin de piste, position hors fenêtre, EOF déjà envoyé. `open_and_seek_clip` ne peut pas rencontrer ça (ses décodeurs sont neufs). Quand le seek webcam ne rendait rien, `compose_frame` recevait un `AVFrame` vide, `nv12_srvs` échouait avec « frame sans texture D3D11 », et le thread de rendu S'ARRÊTAIT DÉFINITIVEMENT — preview noire jusqu'à recréation de la vue. Constaté en usage, et invisible dans les métriques : sans preview, tous les indicateurs de fluidité s'améliorent, ce qui rendait la régression facile à prendre pour un gain. `seek_active` rend désormais `bool`. Faux → l'appelant retombe sur `set_active_clip`, chemin connu comme sûr. L'optimisation ne s'applique que là où elle fonctionne démontrablement, et son échec n'est jamais fatal. Le vidage du cache de SRV est conditionné à `repositioned` et non plus à `same_media` : un repli a bien fermé des décodeurs, même à médias identiques. REDONDANCE — la télémétrie curseur était relue depuis le disque (ouverture + parse JSON) à CHAQUE demande de clip, y compris quand seul le segment changeait, où le chemin est par construction identique. Mesuré : 66 bascules sur un scrub de deux clips, donc 66 relectures du même fichier. Elle n'est rechargée que si le chemin change ; l'application au compositeur, elle, reste inconditionnelle (une reconstruction du compositeur lui fait perdre son curseur). Le log passe dans la branche de relecture : il annonçait « loaded=ok » alors que plus rien n'était lu. Vérifié : 101 tests du crate, plus un harnais headless qui exerce six `setActiveClip` à fichiers identiques sur des positions variées (dont les bords 0 s et 24,5 s) et confirme que des frames continuent d'arriver — c'est précisément ce que je n'avais pas vérifié avant de livrer la régression.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Base automatically changed from
perf/preview-optim-incrementale
to
release/v1.8.0
July 30, 2026 08:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suite de #206, sur la même méthode : un changement à la fois, mesuré avant/après.
Contrairement à #206, cette branche embarque son instrument. C'est lui qui a désigné
les cibles, et c'est lui qui a réfuté deux hypothèses avant qu'on ne code dessus.
Résultats mesurés
Même parcours à chaque fois (~20 s de scrub par clip, deux franchissements voulus) :
scrub, gros bucketsscrub+previewOnze fois moins de temps bloquant sur le thread principal.
Ce que contient la branche
f245ad7c— sonde de fluidité. Diagnostic activable à la demande(
window.__uiProbe.start()), inerte sinon. Segmente les intervalles rAF par état(repos / preview / scrub / scrub+preview) ET par nombre de franchissements de clip,
compte les frames > 25 et > 40 ms plutôt qu'une moyenne, nomme les tâches longues, et
refuse de mesurer une fenêtre cachée. Chaque contrainte vient d'une mesure précédente
qui s'était révélée fausse pour cette raison exacte.
67211a7b— ne plus rouvrir les décodeurs quand seul le segment change.resolveVisibleClipsdécoupe les clips aux trims ; deux segments d'un même clip pointentsur le même fichier. Chaque frontière de segment déclenchait pourtant deux
Decoder::open+avformat_find_stream_info+ init D3D11VA. Mesuré : 31 bascules pour2 changements de clip voulus.
0b4cdd73— vider le cache de SRV quand un jeu de décodeurs est fermé.clear_srv_cacheexistait, documentée pour ce cas précis, sans aucun appelant. Le cacheest keyé sur l'ADRESSE de la texture : garder les entrées d'un décodeur fermé fait fuir
de la VRAM et, en cas de réutilisation d'adresse, fait rendre l'image du clip précédent.
faaf0f1e— repli sûr, et ne plus relire le curseur pour rien. Voir « régression »ci-dessous. Au passage : la télémétrie curseur était relue du disque à chaque demande de
clip, chemin identique compris — 66 relectures du même fichier sur un scrub.
a06599cbannule un throttle desetScrubbingTimeSec: appliqué puis reverté, sanseffet mesurable. L'écart qui l'avait motivé venait d'une variable cachée (le
franchissement de clip), pas du code.
Une régression introduite puis corrigée, à connaître
seek_activemarquait la frame comme utilisable sans vérifier les DEUX seeks. Ce cheminhérite de décodeurs déjà ouverts, donc d'un état ; quand le seek webcam ne rendait rien,
compose_framerecevait unAVFramevide et le thread de rendu s'arrêtaitDÉFINITIVEMENT — preview noire.
Le mode de défaillance mérite d'être retenu : sans preview, tous les indicateurs de
fluidité s'améliorent. Le rapport de sonde correspondant était le meilleur de la
session. Une régression qui supprime le travail est indiscernable d'une optimisation
réussie si l'on ne regarde que les métriques. C'est l'utilisateur qui l'a vu.
Le repli est désormais explicite :
seek_activerend un booléen, faux →set_active_clip.Vérifié par un harnais headless qui exerce six
setActiveClipà fichiers identiques surdes positions variées, bords inclus.
Ce qui n'est PAS résolu
La dégradation au repos persiste :
repos@0= 0,0 % de frames > 25 ms,repos@47=13,2 %. Le vidage du cache de SRV ne l'explique donc pas, contrairement à ce que suggère
son message de commit — je le note ici plutôt que de réécrire l'historique.
La forme du défaut a changé, ce qui est une piste :
repos@47a un max de 25,8 ms et0 % de frames au-delà de 40 ms. Plus aucun pic, mais une cadence stable à 25 ms au lieu
de 16,7. Ce n'est plus de la saccade, c'est un régime plus lent après de nombreux
franchissements — un phénomène différent, à investiguer séparément.
0b4cdd73reste défendable sur ses autres mérites (fuite de VRAM, et surtout collisiond'adresse pouvant faire rendre le mauvais clip), mais pas sur celui-là.
Vérifié
cargo test -p openscreen-compositor: 101 tests.tsc --noEmitpropre. Tests vitest deszones touchées. Harnais headless pour le chemin
seek_active. Chaque commit testé dansl'app avant le suivant.