Na obsah stránky

Optimalizace genarátoru náhledů

Aleš Roubíček | | # permalink

O tom jak generuju náhledy pro socky, jsem tu už psal. Od té doby jsem vyměnil Puppeteer za Playwright, vytvořil Page Object Model a taky začal narážet na delší a delší doby buildu…

Následující řádky nastíní, jak jsem se s tím popral.

Problémy

Letmým pohledem jsem zjistil, že generování náhledů v CI trvá zhruba minutu a instalace Playwright závislostí (browserů) si bere dalších 20s i v krocích, které je nepotřebují. S obojím se dá něco dělat.

Paralelizace asynchronních úloh

Nejprve jsem optimalizoval tvorbu obrázků. Obrázky se generují pěkně jeden za druhým, takže je tu prostor k paralelizaci.

Původní kód měl optimalizaci v tom, že otevřel jedno okno browseru, načetl vzorový HTML dokument a pak už jen mění patřičná místa v DOMu a dělá screenshot. To se úplně paralelizovat nedá, docílil bych akorát race conditions s náhodně chybnými výsledky.

const page = await browser.newPage();
const twittedCardPage = new TwitterCardPage(page);
await twittedCardPage.navigate(url);
for (const post of data) {
  await generateCard(twittedCardPage, post);
}

Takže jsem začal de-optimalizací, kdy si každý obrázek otevře svůj vlastní tab a pak ho zase zavře, ať nežereme moc prostředků – mít v browseru otevřených skoro 500 tabů není nikdy dobré.

for (const post of data) {
  const page = await browser.newPage();
  const twittedCardPage = new TwitterCardPage(page);
  await twittedCardPage.navigate(url);
  await generateCard(twittedCardPage, post);
  await page.close();
}

Tohle už se paralelizovat dá, protože má každý obrázek svůj sandbox, ve kterém může bezpečně měnit DOM, aniž by ovlivňoval render jiného obrázku. Ale taky je to dost neefektivní, protože otevření nového tabu a načtení dokumentu jsou relativně drahé operace. A když je něco drahé, tak je potřeba se o to podělit.

Nejprve si připravím browser, aby si otevřel několik tabů a načetl do nich vzorový dokument:

const tabsPool = [];
for (let i = 0; i < POOL_SIZE; i++) {
  const page = await browser.newPage();
  const twittedCardPage = new TwitterCardPage(page);
  await twittedCardPage.navigate(url);
  tabsPool.push(twittedCardPage);
}

Potom si rozdělím data na kousky o stejném počtu, kolik mám otevřených tabů, a spustím tolik asynchronních úloh vedle sebe.

for (const chunk of partition(POOL_SIZE, true, data)) {
  await Promise.all(chunk.map((post, i) => generateCard(tabsPool[i], post)));
}

Funkce partition je transducer z balíčku @thi.ng/transducers, který jako parametry bere počet kousků, po kterých se má původní kolekce rozdělit, zda povolíme poslednímu kousku mít jiný počet prvků, než definuje první parametr, a nepovinné iterable, ze kterého se přímo čte.

await Promise.all počká, až všechny úlohy úspěšně doběhnou, abychom mohli bezpečně měnit dokumenty v další dávce.

No a je to.

Na CI serveru jsem změnil recource_class na large, abych dostal víc paměti a CPUček, a POOL_SIZE nastavil na 8. Vzhledem k tomu, že děláme screenshot, který ukládáme do souboru, máme tu IO bound konkurenci. Větší pool nám nepřinese žádné zrychlení, protože se nám pak dusí fronta zápisu na disk.

Ověřeno experimentálně za vás.

Čas generování se na CI zkrátil na polovinu, tj. opět někde okolo 30s.

Na to, jak jsem optimalizoval instalace závislostí, se podíváme někdy příště.

Našli jste v článku chybu? Máte námět na reportáž? Založte mi ticket.