← Všechny články

Jak GitHub Copilot app zvládá pull requesty s milionem změněných řádků

GitHub přepracoval zobrazení změn v GitHub Copilot app tak, aby plynule zvládalo i pull request s více než milionem změněných řádků a stovkami komentářů. Článek vysvětluje architekturu nového zobrazení i automatizované testování; regionální ani licenční omezení neuvádí.

Neoficiální české shrnutí a překlad článku Microsoftu. Web není oficiálním kanálem Microsoftu. Originál: Rendering huge pull requests in the GitHub Copilot app (GitHub Blog).

Rozsáhlé refaktoringy a migrace je často nutné začlenit jako jedinou změnu.

Stacked pull requests umožňují rozdělit práci na menší změny, které se snáze kontrolují a týmům pomáhají snižovat riziko při nasazování. Některé změny ale nejdou rozumně rozdělit. Výsledkem je jediný pull request, který může být velmi rozsáhlý a s přibývající diskusí při kontrole dál narůstá.

Kontrola kódu musí zůstat rychlá a plynulá i tehdy, když jsou diff i související diskuse obrovské. Právě s tímto požadavkem tým GitHubu přepracoval zobrazení pull requestů v GitHub Copilot app.

Pro ověření jeho možností otevřel největší pull request, který se mu podařilo najít: v open source projektu, s 2 200 soubory, více než milionem změněných řádků a více než 400 komentáři přímo u kódu. Následující postup umožnil zajistit svižné zobrazení i tohoto extrémního případu.

Jak rychle vykreslit velký diff, je dobře známé: virtualizovat řádky, udržovat malý počet prvků vložených do DOM a využít toho, že každý řádek představuje řádek kódu se známou výškou.

Obtížnější jsou komentáře. Jejich výška závisí na zalamování Markdownu, rozbalitelných částech, přítomnosti pole pro odpověď i na tom, zda se už načetly obrázky. To vše lze zjistit až při vykreslování. Je proto potřeba jiná architektura.

Tým řešil tři problémy:

  1. Měření. Výšku komentáře nelze zjistit, dokud se nevykreslí. Tím přestává platit předpoklad, díky kterému zůstávají velké diffy při posouvání svižné.
  2. Datová pipeline. Rychlé zobrazení diffu nepomůže, pokud se zadrhává přísun dat nebo se zahazuje už hotová práce.
  3. Hledání chyb v praxi. Tyto problémy se projevují pod zátěží, na konkrétním vykreslovacím enginu a v konkrétní pozici dokumentu. Tým proto definoval, jak má správné chování vypadat, doplnil odpovídající měření a celý cyklus změna → měření → zlepšení spouštěl bez obsluhy.

Prvním krokem bylo pochopit geometrii, díky které je diff obsahující pouze kód rychlý. Jakmile se přidají komentáře, tato geometrie už nestačí.

Na stránku nelze vložit milion uzlů DOM. Standardním řešením je virtualizace: do DOM se vloží jen řádky viditelné na obrazovce a malá rezerva kolem nich. Při posouvání se stejné prvky DOM znovu používají. Seznam se chová, jako by existoval celý milion řádků. Posuvník má správnou velikost a přechod na konkrétní řádek funguje. Ve skutečnosti ale v jednu chvíli existuje jen přibližně 100 řádků.

Aby tato iluze fungovala, musí něco dodávat údaje o geometrii. Celková výška posouvaného obsahu je součtem výšek všech řádků. Pozice řádku N je součtem výšek řádků nad ním. Přechod na řádek, vykreslení posuvníku i určení viditelného obsahu jsou výpočty nad tabulkou výšek. Tuto tabulku lze sestavit z odhadů a postupně ji opravovat podle naměřených hodnot. Univerzální virtualizační nástroje pro proměnlivou výšku to dělají právě tak.

Pokud je ale každý řádek řádkem kódu se známou velikostí písma, odhady nejsou potřeba. Celou tabulku lze vypočítat předem a už se nemění, takže později není co opravovat.

Tento princip lze označit jako pravidlo „všechny výšky jsou známé před vykreslením“. Zobrazení diffu v GitHub Copilot app na něm staví:

  • Imperativní vykreslování s opakovaným využíváním prvků řádků kódu, bez samostatné komponenty React pro každý řádek.
  • Geometrie uložená v typovaných polích pro výpočet pozic.
  • Dokumenty diffů spravované backendem a streamované tak, aby nejprve dorazila jejich struktura.
  • Imperativní API pro posouvání s přesným přechodem na řádek N.

Žádná z těchto částí neškáluje špatně, protože množství práce v jednotlivých snímcích neroste s celkovým počtem řádků. Pro samotný kód je tento návrh správný a tým jej zachoval.

Co se ale stane, když se doprostřed diffu vloží vlákno komentářů? Jak bude vysoké?

Bez vykreslení to nelze vědět. Jeho výška závisí na věcech, které jsou známé až při vykreslování a mohou se měnit i po prvním zobrazení:

  • Markdown se při různých šířkách různě zalamuje.
  • Bloky <details> může uživatel přímo v dokumentu rozbalovat a sbalovat.
  • Editor odpovědi se otevře uvnitř existujícího vlákna a při psaní roste.
  • Prostor zabírají také diffy navržených změn, reakce, režim úprav a oznámení o vyřešení vlákna.
  • Obrázky a další asynchronně načítané prostředky po dokončení načítání mění výšku obsahu.

Zdánlivě jednoduchým řešením je pro každý komentář rezervovat místo s pevnou výškou určenou odhadem. U velkého pull requestu ale tento přístup selhává. Odhad, který v průměru vychází správně, se stále mýlí v krajních případech. Většině komentářů přidělí příliš mnoho prostoru, takže vznikají prázdné mezery. Náročnějším komentářům naopak přidělí prostoru málo, takže se oříznou nebo dostanou vlastní vnořený posuvník. Pokud se po vykreslení změří skutečná výška a zapíše zpět do společné tabulky pozic, posune se vše pod komentářem – ve chvíli, kdy už uživatel posouvá dokument. Výsledkem je skok v zobrazení, který může být u velkého pull requestu výrazný.

Komentáře proto potřebují jiná pravidla. Požadavek „všechny výšky jsou známé před vykreslením“ pro ně splnit nelze. Místo toho lze zajistit, že výšky mají stanovené meze, měří se až podle potřeby a opravy jsou malé a ukotvené k obsahu, který si uživatel právě prohlíží.

Klíčové bylo přestat nutit jedinou geometrii obsluhovat oba druhy obsahu. Tým rozdělil výšku dokumentu do dvou nezávislých oblastí:

total height = deterministic code height          (exact, known up front)
             + Σ dynamic block effective heights   (estimated, then measured)
             + scroll padding

Geometrie kódu zachovává původní přístup. Je deterministická, přesná, využívá prefixové součty a při změně velikosti komentáře se nikdy nepřepočítává.

Geometrie dynamických bloků zahrnuje vše, u čeho nelze výšku předpovědět: například vlákna komentářů, rozepsané komentáře a editory odpovědí. Každý blok je identifikován podle toho, čím je, nikoli podle své aktuální pozice. Má stabilní klíč, který se nemění ani během načítání obsahu. Je ukotven k souboru, řádku a straně diffu, nikoli k pixelové souřadnici, takže se při přeuspořádání obsahu neztratí. Systém také uchovává otisk všeho, co může výšku bloku změnit: jeho obsahu, stavu rozbalení <details> nebo aktivního editoru odpovědi. Zaznamenává i šířku při posledním měření, zařazenou do intervalů, aby běžná změna velikosti okna nezneplatnila všechna měření v dokumentu.

Výsledná výška bloku se pak určí jednoduše: použije se platná naměřená výška; případně výška z mezipaměti, pokud stále odpovídají otisk a šířka; jinak odhad. Tyto výšky mají vlastní index oddělený od řádků kódu, takže změna velikosti komentáře nikdy nevynutí přepočet geometrie kódu. Počet bloků navíc závisí na počtu komentářů, nikoli řádků. Několik tisíc bloků nepředstavuje problém, pokud se při prvním vykreslení všechny najednou nevkládají do DOM ani neměří.

Správné řešení této části trvalo nejdéle, protože první návrh týmu byl chybný – a ukázal důležitou slabinu.

Přirozeným způsobem měření dynamického obsahu je jeden ResizeObserver pro každý blok. Sleduje prvek a při každé změně zapisuje jeho naměřenou výšku zpět do rozvržení. Takové řešení tým nejprve navrhl, ale při optimalizaci výkonu jej zavrhl. Vytváří totiž zpětnovazební smyčku, které se velká virtualizovaná zobrazení musí vyhnout. Observer zapisující výšku do rozvržení sledovaného prvku může znovu spustit sám sebe a náklady rostou s každým blokem vloženým do DOM.

Do výsledného řešení se místo toho dostal jediný měřicí průchod, jehož spuštění se řídí nečinností a stavem posouvání. Dodržuje stejně přísná pravidla jako deterministická část:

  • Mimo kritickou cestu. Spouští se, až se viditelný rozsah ustálí, nikoli v každém snímku při posouvání. Dokud posouvání probíhá, vůbec neběží. Přepočet rozvržení během posouvání by způsobil právě to škubání, kterému se má řešení vyhnout. Měření se znovu spustí po zastavení posouvání.
  • Omezení na okolí viditelné oblasti. Kandidáty jsou pouze bloky vzdálené přibližně do 2 400 px od viditelné oblasti. Množství práce tak odpovídá O(viewport). U vzdálenějších bloků se dál používá odhad, který se opraví, až se přiblíží.
  • Přednost mají měření vykreslených bloků. U bloku vloženého do DOM je skutečným zdrojem pravdy jeho vykreslená výška. Průchod přečte všechny takové kandidáty v jedné dávce, při jediném přepočtu rozvržení a bez průběžných zápisů, a výsledky zaznamená. Blok vložený do DOM se nikdy nepřeskočí ve prospěch zastaralého odhadu. Toto pravidlo vyřešilo nejnepříjemnější chybu: pod některými komentáři zůstával pruh prázdného místa, protože blok vypadl z měření a dál používal příliš vysoký odhad.
  • Měření mimo obrazovku je omezená záloha. Pro blízký blok, který ještě není vložený do DOM, provede průchod nejvýše jedno vykreslení mimo obrazovku. Tím opraví rezervovanou výšku dříve, než se blok objeví. U bloků vyšších než viditelná oblast vynechá i tento krok. Nadbytečně rezervované místo je pod spodním okrajem obrazovky, takže se za takové vykreslení nevyplatí platit výkonem.
  • Zbytek zachytí observer. Některé změny výšky nemění otisk a nesouvisejí s posouváním: psaní odpovědi, dokončení načítání obrázku nebo přepnutí <details>. Každý blok vložený do DOM si ponechává ResizeObserver, který ale ve výchozím stavu pouze označí blok k novému měření při průchodu v nečinnosti. Sám výšku nikdy nezapisuje, protože právě tím by vznikla zamítnutá zpětnovazební smyčka. Při odebrání bloku z DOM se observer odpojí a neaktivní karta pull requestu nesleduje nic.
  • S jednou záměrnou výjimkou. Čekání bylo viditelně nevhodné při změnách vyvolaných přímo interakcí nebo novým obsahem: rozbalení <details>, otevření editoru odpovědi či načtení obrázku. Blok vyrostl okamžitě, ale kód pod ním se posunul až při dalším měřicím průchodu. Po jeden snímek tak byl komentář vyšší, zatímco vše pod ním zůstalo na původním místě. Změna byla viditelně dvoukroková. Pokud je proto blok vložený do DOM a viditelný na obrazovce, observer jej nyní změří a provede opravu ve stejném snímku, ještě před vykreslením. Blok vyroste, kód se přemístí a vše pod ním se posune současně. Dvě pojistky brání vzniku zpětnovazební smyčky: nejvýše jeden synchronní zápis změn za snímek, takže se série změn sloučí, a zákaz takového zápisu během aktivního posouvání, kdy se použije dávkový průchod.

Když se naměřená výška liší od odhadu, změní se výpočty pro posuvník. Naivní implementace způsobí skok viditelné oblasti. Řešením je opravovat pozici podle identity obsahu, nikoli podle pixelů:

  1. Před aktualizací výšek zaznamenat, k čemu je pohled uživatele ukotven – identitu řádku nebo bloku a posun uvnitř něj.
  2. Použít rozdíly ve výškách.
  3. Zjistit novou pixelovou pozici stejného kotevního bodu.
  4. Upravit posunutí tak, aby kotevní bod zůstal na stejném místě obrazovky.

Přirozené chování zajišťuje ještě několik pravidel:

  • Změní-li výšku blok nad viditelnou oblastí, pozice se upraví o rozdíl výšek. Tím zůstane zachováno místo, které si prohlížíte.
  • Doplní-li se obsah pod viditelnou oblastí, pozice se neupravuje. Změnu nevidíte.
  • Pokud Vy rozbalíte <details> nebo otevřete odpověď ve viditelném bloku, kompenzace změny výšky nad kotevním bodem se pro tento blok potlačí. Interakce tak působí přímo a obsah pod blokem přirozeně odteče dolů.
  • Korekce nesmí bojovat s aktivním posouváním ukazatelem ani se setrvačností kolečka. Provede se dávkově po dokončení snímku.

Poslední pravidlo mělo záludný okrajový případ. Podmínka „neopravovat pozici, když uživatel posouvá dokument“ byla implementována podle času posledního zaznamenaného posunu. Tento čas ale aktualizovalo i programové posouvání. Zobrazení nebo skrytí postranního stromu souborů mění šířku panelu diffu. Při zapnutém zalamování se každý zalomený řádek nad aktuální pozicí může přeskupit na jiný počet vizuálních řádků, změní se celý souřadnicový prostor a zobrazení při ustalování samo provede malý posun. Ochranná podmínka to vyhodnotila jako nedávné posouvání uživatelem a přeskočila právě tu korekci, která měla zachovat jeho místo. Čtený soubor tak zmizel z obrazovky. Řešením bylo rozlišit posouvání vyvolané uživatelem od posouvání způsobeného samotným zobrazením. Jakákoli kontrola uživatelské interakce musí být navržena tak, aby ji nemohly splnit vlastní vedlejší účinky aplikace.

Korekce tak zůstávají malé, využívají už existující měření a sledují obsah, který si právě prohlížíte.

Zobrazení diffu může být jen tak rychlé, jak rychlý je přísun dat. Možnosti uživatelského rozhraní formovaly tři zásady z datové části řešení. První je streamovat strukturu před obsahem. Diff se načítá postupně, takže strom souborů a metadata se zobrazí už během načítání dokumentu. Úplná sada vláken komentářů se přitom určí předem, místo aby vlákna přibývala po částech. Druhou zásadou je odkládat práci s jednotlivými položkami, dokud není potřeba. Zvýrazňování syntaxe běží mimo hlavní vlákno, takže řádky se ihned zobrazí jako prostý text a obarví se po doručení výsledků. Zvýraznění tak zobrazení vylepšuje, ale neblokuje posouvání. Stejně funguje zpracování rozsáhlého Markdownu a kontextu navržených změn: nic se nepřipravuje, dokud se obsah nepřiblíží viditelné oblasti.

Třetí zásada se týká toho, které výsledky práce stojí za to uchovat. Uvolnit dokument diffu při odchodu jinam je správné výchozí chování. Tyto dokumenty jsou velké a uchovávání všech navštívených diffů by během dlouhé relace vedlo k vysoké spotřebě paměti. Metadata pull requestu ale zůstávají, takže při návratu se okolní rozhraní, záhlaví i strom souborů zobrazí okamžitě. Potom několik sekund čekají na diff, který byl ještě před chvílí kompletní. Okamžitě zobrazený rámec kolem prázdného diffu působí jako chyba, přestože je celkové čekání kratší. Základní pravidlo proto zůstalo, ale přibyla mezipaměť: posledních několik diffů zůstává v paměti, starší se uvolňují a aktualizace na pozadí zjišťuje, zda některý z uložených diffů nezastaral.

Téměř každá chyba v tomto projektu zůstávala skrytá, dokud nenastala konkrétní situace. Ruční reprodukce byla velmi obtížná. Typické hlášení vypadalo takto: „Pod některými komentáři se objeví pruh prázdného místa, ale jen někdy, jen u velkých pull requestů, a když odjedete jinam a vrátíte se, zmizí.“ Takovou chybu nelze spolehlivě ladit pouhým sledováním obrazovky, proto tým vytvořil nástroje pro systematickou diagnostiku.

Naivní postup spočívá v přidání volání console.log, ručním průchodu scénářem, zkopírování výstupu a jeho předání někomu – nebo něčemu – k analýze. Potom se logování odstraní a vše se opakuje. Je to pomalé, vyžaduje to člověka v každém cyklu a v nejhorším případě se měří spíše vliv dodatečného měřicího kódu než skutečné chování aplikace.

Zobrazení proto obsahuje trvalé strukturované sondy sledující podmínky, které mají vždy platit. Při každém vykreslení odpovídají na jednoduché otázky:

  • Je množství vykresleného obsahu skutečně omezené viditelnou oblastí? Kolik řádků a bloků komentářů je právě vložených do DOM?
  • Slučuje se měření do jediného zápisu změn za snímek a jak dlouho tento snímek trvá?
  • Jak velké jsou prováděné korekce posunutí?
  • Byl po zahájení posouvání vložen nějaký nový blok komentáře? Jakmile dorazí informace o struktuře z backendu, musí být tento počet nulový.
  • Odpojují se observery jednotlivých bloků při odebrání z DOM, nebo po každém bloku zůstává jeden neodpojený observer?

Jde o objektivní signály úspěchu či selhání. V end-to-end testu nad syntetickým obrovským pull requestem s mnoha komentáři se ověřují proti stanoveným limitům. CI tak dokáže určit, zda se zobrazení chová správně.

Ústředním prvkem byl autonomní cyklus změna → měření → zlepšení, který běžel dvěma způsoby.

Diagnostika bez grafického rozhraní spouštěla deklarativně popsaný scénář – otevření pull requestu, posun na poměrnou pozici, přepnutí bloku <details>, změnu velikosti okna – proti simulovanému serveru. Přitom četla vlastní produkční diagnostická data aplikace: počty vykreslení Reactu, časovou osu výkonu a vzorkování přes requestAnimationFrame pro detekci trhání. Celý cyklus měření, ovládání, sběru, analýzy a seřazení výsledků proběhl automaticky a výstupem byl seznam úzkých míst podle významu. Protože scénář tvoří pouze JSON předaný sondě za běhu, mohl agent profilovat libovolný postup na základě popisu v běžné angličtině, bez úpravy jediného řádku zdrojového kódu.

Autopilot bez obsluhy opakovaně ovládal skutečnou desktopovou aplikaci při práci s obrovským pull requestem. Nejprve prošel stav s dosud nenačtenými komentáři, místo nichž byly jen zástupné prvky, a pak stav s načteným obsahem. Rozbaloval a sbaloval <details>, otevíral a rušil editory odpovědí, sbaloval a rozbaloval soubory, přepínal postranní strom, procházel hluboko do seznamu souborů a měnil velikost okna. Každé měření se zároveň zapisovalo do logu aplikace na disku, takže agent mohl číst údaje o chování za běhu bez člověka u klávesnice. Každý vzorek obsahoval ukazatel správného fungování, který sloužil jako objektivní kontrola. Vzorek s načtenými komentáři vyhověl pouze tehdy, pokud v celém rozsahu posouvání nebyly nevyplněné mezery mezi komentáři ani prázdné bloky a skutečný obsah vláken byl opravdu vložen do DOM. To platilo i pro průchod soubory hluboko v seznamu.

Tým používal tento cyklus:

  1. Reprodukovat bez obsluhy na skutečném enginu. Spustit autopilota, nechat jej opakovat scénář a přečíst log na disku.
  2. Zjišťovat chyby podle ukazatele správného fungování, nikoli pohledem. Rozhodující jsou údaje ve vzorcích.
  3. Proměřit podezřelé místo. Když ukazatel zaznamená problém, přidat na dané místo jednu úzce zaměřenou strukturovanou sondu, znovu spustit měření a přečíst výsledky. Úprava zobrazení se za běhu načte do otevřeného okna a znovu aktivuje autopilota, takže nový záznam je k dispozici přibližně po jednom cyklu.
  4. Odstranit pomocné konstrukce. Jakmile je jasné, jaká podmínka musí vždy platit, zachytit ji v testu a návrhové dokumentaci. Ponechat pouze signály potřebné pro spolehlivou detekci.

Kontrola takto velkého pull requestu dříve znamenala buď čekat, nebo to vzdát a číst jej jinde. Kontrola kódu ale není dokument se známými rozměry. Je to konverzace, která během čtení mění tvar. Podkladové zobrazení s tím musí počítat od začátku, ne to dodatečně řešit záplatami.

Výsledkem je zobrazení pull requestu, ve kterém se diff s milionem řádků a stovkami komentářů ve vláknech otevírá, posouvá a chová jako běžně velký pull request. Komentáře se vykreslují celé, místo aby byly oříznuté do boxu s vlastním posuvníkem. Rozbalení sbalené části posune pouze kód pod ní. Návrat k právě opuštěnému pull requestu obnoví místo, kde jste skončili.

Pokud kontrolujete kód pravidelně, rozdíl můžete vyzkoušet na pull requestu, o kterém už víte, že je problematický. Otevřete ten nejnáročnější, který máte k dispozici.