Dneska to bude relativně krátké. S každou další verzí C++ lze víc a víc algoritmů přesunout do constexpr „domény“, ve které se výpočty provádí během překladu a v runtime se již používají výsledky toho výpočtu. Často ale vstupem do takového výpočtu je hodnota získaná až v runtime.
Doména constexpr je velice silná a s každou novou verzí C++ se rozšiřují možnosti toho co všechno si lze dovolit v contexpr provádět. K tomu se pak vážou další možnosti optimalizace kódu, a výsledkem je samozřejmě efektivnější běh kódu.
Pokud se nám „nějak“ podaří namapovat hodnotu z runtime na integrální konstantu (integral constant), pak se nám otevírá obrovský svět, ve kterém můžeme tuhle konstantu použít nejen k výpočtu během překladu, ale tato konstanta může použita jako parametr šablony. Představte si, že bych mohl udělat následující:
template<int i> struct NejakaStruktura; auto funkce_ktera_nejde_prelozit(int v) { return NejakaStruktura<v>(); }
Výše zmíněný příklad nepřeložíme, protože proměnná v není integralni konstantou, je dodaná až v runtime. Překladač ale musí znát podobu typu NejakaStruktura<int> už během překladu.
Jiné použití je například u typu std::variant<>. Tento typ umožňuje definovat varianty pro jednu proměnnou (jako union). Například
std::variant<int, std::string, bool, double> my_variant(42);
S takovou proměnnou mohu pracovat následovně
Ten index lze použít například při serializaci, kdy do výsledného streamu uložíme index varianty a pak provedeme serializaci vlastní varianty. Při deserializaci stačí jen vyzvednout index, inicializovat variant podle indexu a provést deserializace a hotovo.
Až na jeden problém, neexistuje standardní způsob, jak inicializovat variantu na základě indexu z runtime.
Standardní cestou je v zásadě switch-case s variantami
using MyVariant = std::variant<int, std::string, bool, double> MyVariant deserialize(Stream stream,int index) { switch (index) { case 0: return deserialize<int>(stream); case 1: return deserialize<std::string>(stream); case 2: return deserialize<bool>(stream); case 3: return deserialize<double>(stream); } }
Nutnost použít switch-case není vůbec generické, a nedokážu si představit podobný nástroj napsaný pro obecný std::variant<Types…>. Ze části STL, která nabízí šablony pro metaprograming můžeme použít typ std::variant_alternative_t<index,std::variant<…> >. Jako by se nabízelo to napsat takto:
using MyVariant = std::variant<int, std::string, bool, double>
MyVariant deserialize(Stream stream,int index) { using Type = std::variant_alternative_t<index,MyVariant>; return deserialize<Type>(stream); } //toto se nepřeloží
Problém je, že index musí být integralní konstanta.
Zdá se naprosto nelogické chtít v constexpr doméně používat hodnotu z runtime. Přece v době běhu programu je tahle doména pevně „zapečena“ do kódu a nemůže dynamicky reagovat na stav runtime. Proto to je koneckonců const-expression.
Leda, že by constexpr doména připravila všechny varianty a pak na základě dodané hodnoty vybrala příslušnou variant. To by šlo, ne?
Vraťme se k řešení switchem. Co to vlastně představuje? Switch je často řešen jako jump-table. Překladač vygeneruje tabulku adres na kterých začínají jednotlivé varianty. Při běhu pak mapuje proměnnou, podle které se vybírá varianta na index do této tabulky a skáče na adresu uloženou právě na daném indexu této tabulce. Skok realizuje přímo instrukce jmp.
Stejnou myšlenku by šlo použít i v tomto případě. Stačilo by si programově připravit tabulku adres na funkce, které představují jednotlivé varianty. Tyto varianty budou volat nějakou naši lambda funkci podobně jako funguje std::visit. Představuju si následující rozhraní
template<int min, int max, typename Fn> auto number_to_constant(int number, Fn &&fn);
A použití třeba takto:
MyVariant create_variant_by_index(int index, Stream stream) { constexpr int size = std::variant_size_v<MyVariant>; return number_to_constant<0,size-1 >(index, [&](auto v){ using Type = std::variant_alternative_t<v.value,MyVariant>; return deserialize<Type>(stream);
}); }
Tento kód by se přeložil za předpokladu, že proměnná auto v byla nějakého typu, který nese integralni konstantu v proměnné value (takže v.value je integralní konstanta, kterou lze použít ve std::variant_alternative_t). Takový typ může vypadat následovně
template<int i> struct ConstInteger { static constexpr int value = i; };
Struktura deklaruje proměnnou value která je konstantou. A tuto hodnotu lze skutečně použít jako parametr šablony. Pak by mělo být možné kód přeložit. Jak naprogramovat funkci number_to_constant
.
Nutnost definovat číselný rozsah min a max ve funkci number_to_constant má samozřejmě význam. Z rozsahu si totiž odvodíme velikost naší skákací tabulky. Není možné mapovat libovolnou hodnotu na konstantu, protože celkové množství intů je … hodně moc… a kód připravený na tolik variant by byl obrovský. To není potřeba, množství variant bude vždycky výrazně menší.
Naši jump-table nám pomůže vytvořit constexpr objekt. To je objekt, který se instanciuje během překladu a do finální binárky vstupuje již inicializovaný.
1 template<typename Fn, int min, int max> 2 class JumpTable { 3 public: 4 static_assert(min <= max); 5 6 using Ret = decltype(std::declval<Fn>()(std::declval<ConstInteger<min> >())); 7 static constexpr unsigned int size = max - min + 1; 8 9 constexpr JumpTable() { 10 init_jump_table<min>(_jumpTable); 11 } 12 13 constexpr Ret visit(int value, Fn &&fn) const { 14 auto index = static_cast<unsigned int>(value - min); 15 if (index >= size) fn(ConstError{}); 16 return _jumpTable[index](std::forward<Fn>(fn)); 17 } 18 19 protected: 20 21 template<int i> 22 static Ret call_fn(Fn &&fn) {return fn(ConstInteger<i>());} 23 24 using FnPtr = Ret (*)(Fn &&fn); 25 FnPtr _jumpTable[size] = {}; 26 27 template<int i> 28 constexpr void init_jump_table(FnPtr *ptr) { 29 if constexpr(i <= max) { 30 *ptr = &call_fn<i>; 31 init_jump_table<i+1>(ptr+1); 32 } 33 } 34 };
Funkce number_to_constant pak vypadá takto:
template<int min, int max, typename Fn> auto number_to_constant(int number, Fn &&fn) { static constexpr JumpTable<Fn, min, max> jptable; return jptable.visit(number, std::forward<Fn>(fn)); }
Na tuhle situaci je třeba myslet a pro větší pohodlí upravíme třídu nesoucí integrální konstantu a třídu oznamující chybu
template<int i> struct ConstInteger { static constexpr int value = i; static constexpr bool valid = true; }; struct ConstError { static constexpr bool valid = false; };
Proměnnou valid pak použijeme v deserializační funkci pro kontrolu, zda vstupem byl index v daném rozsahu.
MyVariant deserialize(int index, Stream stream) { constexpr int size = std::variant_size_v<MyVariant>; return number_to_constant<0, size-1 >(index, [](auto v){ if constexpr(v.valid) { using Type = std::variant_alternative_t<v.value,MyVariant>; return MyVariant(Type{}); } else { throw std::invalid_argument("Index out of range"); } }); }
Použití if constexpr lze vyřídit situaci kdy v.valid == false, v takovém případě se část, kde se pracuje s v.value vůbec nebude překládat a překladač si nebude stěžovat na neexistenci v.value. Protože jde o integrální konstantu, lze použít if constexpr i v jiných situacích a na základě hodnoty kód větvit, přičemž překladač vždy přeloží jen tu variantu, která se týká dané hodnoty a ostatní tam vůbec nebudou. Lze se takhle úplně vyhnout podmínkám v kódu a konstruovat tak branchless kód.
Jiný příklad – převod utf-8 na wchar skoro branchless
template<typename InputIterator> wchar_t utf8Towchar(InputIterator &at, InputIterator end) { unsigned char c = *at; ++at; int bytes = (c >= 0xC0) + (c >= 0xE0) + (c > 0xF0); return number_to_constant<0,3>(bytes, [&](auto b) ->wchar_t { if constexpr(!b.valid) { return static_cast<wchar_t>(c); } else if constexpr(b.value == 0) { return static_cast<wchar_t>(c); } else { wchar_t ret = static_cast<wchar_t>(c) & (0x3F >> b.value); for (int i = 0; i < b.value; i++) { if (at == end) return -1; unsigned char d = *at; ++at; ret = (ret << 6) | (d ^ 0x80); } return ret; } }); }
; int bytes = (c >= 0xC0) + (c >= 0xE0) + (c > 0xF0);
xor %eax,%eax
cmp $0xbf,%dl ; v dl je hodnota c
seta %al
xor %ecx,%ecx
cmp $0xdf,%dl
seta %cl
add %ecx,%eax
cmp $0xf0,%dl
lea 0x7313(%rip),%rcx
seta %dl
movzbl %dl,%edx
add %edx,%eax ; v eax máme index
call *(%rcx,%rax,8) ; skok na adresu která je obsahem [%rcx+%rax*8]
;gcc řeší jako call patřičné varianty
Udělejme si shrnutí, k čemu se takový nástroj hodí
"Výše zmíněný příklad nepřeložíme, protože proměnná v není *integralni konstantou*." Nemá to bý spíše: není konstatním výrazem (is not a constant expression)?
Jaké? A teď prosím, aby byla zachována hlavní myšlenka, ted nechci aby výsledkem bylo "všechno se řeší v runtime". To umím taky a nepotřebuju k tomu tyhle vychytávky.
To je asi jedno, jasně není constatním integerem. Já slepě následuji kompilátor :D.
Jinak to je tedy maso na čtení tohle C++. To fakt nejde jednodušeji?
Jde to jednodušeji. Třeba zvolit jiný jazyk. Ale je to zajímavé pro lidi, kteří prostě mají ohromné množství existujícího kódu. A tak když přijde nová verze kompilátoru, která umožní postupně přepisovat, tak je to přeci super.
Vždycky to jde jednodušeji, otázkou je, co je jednodušší, jak definuješ jednodušší.
Fascinují mě lidé, kteří hned sahají po jiném jazyce, aniž by se vůbec zajímali, jaký výstup z toho jejich jazyka vylejzá. Jasně, pokud mne nezajímá optimalizace, vždycky to nějak zbastlím.
Stejně jako když se v cizině domluvím cizím jazykem, rukama nohama, oni mě pochopí. Ale jazykový znalec ze mě nebude a nebudu schopen v tom jazyce napsat třeba úžasnou báseň
A o tom to je. Mne skutečně záleží, jaký je výstup, jestli překladač pochopí co po něm chci. A také si chci ušetřit budoucí práci. Šmarjá, mám tu jazyk, kterým mohu programovat kompilátor, doslova ho mohu naučit generovat kód který by normálně negeneroval. Tady ho například vynutím, aby připravil všechny varianty určitého kódu, to kdybych měl dělat ručně, tak se z toho asi zblázním - a nebo bych musel používat externí generátory kódu - nikdy jsem nebyl jejich kamarád, dělají v tom bordel. Který jazyk tohle umí.
Můžeme sahat po nějakých JIT jazycích, kde to tedy nějak funguje, ale tam často se lze jen domnívat, jak výsledek dopadne, co jak formulovat, aby překladač na cílovém stroji generoval co nejoptimálnější kód danému algoritmu pro daný případ.
Já odpovídám tedy za sebe: jednodušeji myšleno nějak čitelněji/stručněji. Mne občas na C++ překvapuje, jak se s nějakým novým konstruktem dají věci hodně zjednodušit. Tohle je fakt těžké na udržení pozornosti a teď samozřejmě nemám čas procházet řádek po řádku. Ale tím nějak nezpochybňuji řešení nebo dokonce jazyk.
"Mne skutečně záleží, jaký je výstup, jestli překladač pochopí co po něm chci." Mne právě zajímá, jestli to řešení pochopím i já jako člověk. Několikrát se mi stalo, že jsem postupně kompilátor donutil k tomu, že rozuměl tomu co potřebuji poté, co jsem si zas něco "nového" načetl z C++11/14/17/20, ale pak sem se k tomu vrátil po měsících a tápal jsem viz různé varianty `enable_if` atd atp. C++ opravdu ve mne někdy zanechává pocit, že si vůbec nejsem jistý, jestli to co píšu, je ten nejlepší způsob jak to psát.
Naštěstí enable_if odzvonilo zavedením konceptů.
Jistě i tenhle jazyk se vyvíjí a pracovat vždycky s tou nejčerstvější verzí znamená maličko problémy. Tak jako se vším následující verze může opravovat chyby v předchozí verzi. Já nedávno přešel na C++20 aktuálně je C++23, a čekám na C++26, abych přešel na C++23.
A neboj, kód by měl být vždycky zpětně kompatibilní. Jen je dobré se seznámit s novými nástroji, jako správný kovář, co nezůstal v minulosti.
Osobně se domnívám, že C++20 udělalo významný krok s konceptama a s možnostmi co lze dělat v constexpr. K tomu přidejme korutiny. Hodně věcí jsem přepsal do C++20 a to i koncepčně, tedy kdy přímo využívám nové featury a nejde jen o tweaky, ale často se mění i způsob práce.
Mám v plánu ještě další články
Jo tak aspoň jste mi potvrdil, že `enable_if` mohlo dělat problém :D, mne tedy přišlo děsně nepřehledné někdy. Koncepty snad jsou lepší, ale číst musíme právě i tu starší code-base a to je peklíčko :D
Asi mi uniká, co že mi to vlastně vyčítáš?
Já jsem C++ opustil, protože nesplňuje moje požadavky. Mezi mé požadavky nepatří potřeba vědět co přesně vylejzá. (To, aby pochopil co přesně chci, to ano.) Nemám k C++ srdeční vztah. Nepotřebuju v něm excelovat. Chci excelovat v produktu. Pokud mi jiný jazyk umožní napsat stejně rychlý, paměťově nenáročný, bezpečnější produkt s čitelnějším kódem, nemám problém C++ opustit.
Ale nechce se mi tady bavit o jiných jazycích, když je to příspěvek o C++.
Nic nevyčítam, jen konstatuju. Někdo se holt spokojí s tím, že to "nějak zbastlí" a je to. Super, i s tím se dá žít.
Je asi rozdíl, pokud mám eshop, kam mi chodí pět zákazníku za hodinu a pokud mám herní server, kde mi přijde tisíce požadavků za sekundu.
Jako autor knihoven mě často zajímá, co z toho vyleze, aby moje knihovna nevytvářela zbytečnou brzdu.
Je to ekvivalentní. Zatímco decltype je jazykový konstrukt, invoke_result je knihovna STL. Oba dělají totéž, akorát ta knihovní verze má asi 100 řádků aby docílila téhož. A ani si nejsem jist, že to je čitelnější. Mám radši decltype pokud to jde.
Co říká profiler na ten utf8 kód? Je to rychlejší než ten rozskok podle počtu znaků udělat naivním switchem? Protože jestli je něco horšího než podmíněný skok, pak je to call na dynamicky spočítanou adresu.
Vychází to stejně. Switch je realizovaný jako JMP s úplně stejným obsahem. Rozdíl v tom, že jedno je CALL a druhý JMP se moc neprojeví, protože optimalizer by CALL strčil na jinou úroveň kódu, třeba do toho push_back, který se skrývá za přiřazení do iterátoru, který v tomhle případě inlinoval. Dělá to podle toho, jak moc vyjde inlinovaný kód dlouhý, když je přes nějaký treshold, rozhodne se tam udělat CALL.
V tom kódu s UTF-8 se navíc rozvine ten cyklus. Kdybys to psal switchem, nejspíš bys to rozvíjel ručně, nebo bys tam ten cyklus nechal. Možná - ale nemám to ověřený - by překladač nějak usoudil, že může vygenerovat varianty toho cyklu podle spočítaného indexu, ale myslím si že ne, alespoň ne GCC. Na to takovou umělou inteligencí neoplývají.
Podmíněný skok je horší, než skok na spočítanou adresu
Ono je to složitější. Kdybych to měl srovna v pořadí od nejefektivnějšího tak to dopadne takto
1) skok na pevnou adresu
2) podmíněný skok který se podaří odhadnout
3) skok na dynamickou adresu
4) volání virtuální funkce
.
.
.
.
.
X) podmíněný skok, který se nepodařilo odhadnout.
- pokud budeš lámat nějaký mezinárodní, čínský text, pak ty podmíněné skoky dost zabijou performanci. Ale myslím si, že to bude znát i u nějakého českého textu
A kde je tam "skok na dynamickou adresu, který se nepodařilo odhadnout"? Ten rozskok podle délky může být pro branch target prediktor taky pěkný oříšek.
Nic takového neexistuje. Samozřejmě, pokud adresa není známa v určitém bodě, musí dekóder instrukcí čekat. Proto ta instrukce LEA pro výpočet začátku tabulky je s předstihem, aby jakmile je znám výsledek EAX, tak se mohlo pokračovat.
Nicméně se domnívám, že zahazování rozpracované větvě při špatném odhadu je mnohem náročnější pro CPU, než prostě počkat, dokud není výsledek znám. On, jakmile je výsledek znám, může se pokračovat v dekódování instrukcí a přitom to ještě neznamená, že by instrukce byla dokončena, tam je ještě nějaký cleanup a commit fáze, Prostě obecně je skok na vypočtenou adresu rychlejší, než špatně odhadnutý podmíněny skok.
Nic takového neexistuje? A co je to teda ta "indirect branch prediction" o které tady Intel píše? https://www.intel.com/content/www/us/en/developer/articles/technical/software-security-guidance/technical-documentation/speculative-execution-side-channel-mitigations.html
Ok, máš pravdu, nejsem znalec všech optimalizačních technik CPU. Vím jen, že branch prediction se sleduje hlavně u IFů.
Chápu ale pořád benefit v tom mít jedno místo, kde ti selže branch prediction je lepší, než jich mít víc. Tedy pokud dokážeš nahradit několik IFů jednou indirekcí, pořád je to win.
Ono není až tak důležité, kolik těch míst je. Ty počty skoků, pro které teď procesory zvládnou udržet predikční data jdou do tisíců (co jsem viděl testy). Daleko víc záleží na tom, jestli ty jednotlivé skoky mají predikovatelné chování. Nahradit jedno větvení se složitým vzorem několika jednoduššími se vyplatí.
U toho utf8 bude silně záviset na datech. Pravidelná čínština bude mít pravděpodobně jiné vzory než ascii se smajlíky. Bez profilování není takřka v lidských silách odhadnout, jak dobře je nějaký kus kódu predikovatelný.
Odstrašující příklad je std::variant. Standard říká, že visit má být O(1), takže tam musí být nějaká jump table dost podobná té vaší. Nestandardní implementace používají jednoduchý switch, který sice není O(1), ale v praktickém nasazení ten standardní přístup drtí tak, že to až hezké není. Ona je ta jump table i dost brutální neprůhledná bariéra pro optimalizátor.
Velké používání instrukcí
call *(%rcx,%rax,8)
jsem viděl právě na std::variant v gcc 11+.
Samozřejmě mě zajímalo, jak se visit přeloží a přeloží se právě přes tento call. Dříve jsem tyhle cally potkával jen u virtuálních metod, takže jsem měl chvíli dojem, že tam mají vtable. Jo byly nějaké starší implementace, které používali switch-case
Tyjo to je dobry. Kdysi jsem resil neco podobnyho. Muj program mel bezet na mainframe a nemohl bezet jako deamon. Pokazde kdyz se spustil dostal nejaka cisla na vstupu a ta musel vyhradavat v binarnim vyhladavacim strome. Vytvareni toho stromu zabiralo vetsinu casu beho toho programu.
Proto jsem potreboval presvedcit kompilator aby ten strom vytvorit uz behem kompilace. Kdyby to nekoho zajimalo tak ta uchylarna je tady:
https://stackoverflow.com/questions/28617781/c-metaprogramming-compile-time-search-tree
A proc si ten strom nemel zakompilovanej jako binarni data ? Nebo si ho mohl mit v souboru a ten pripojit pres shmat. Na to fakt nepotrebujes takovy brikule kompilatoru jako jsou popsany v clanku.
Protoze me to nenapadlo a asi bych s tim mel i problemy (s tim shmat), protoze jsem predtim nikdy nic pro mainframe nenapsal. Byl to financni SW, kde cas od casu nekdo dodal "model" coz bylo nekolik lookup tabulek s rozsahy hodnot.
Procesorovy cas toho mainframe byl extremne drahy a pristup k nemu byl extremne komplikovany. Asi jako kdybych posilal derne stitky ceskou postou.
Navic nam nikdo nechtel dovolit aby ten program bezel ve smycce, po kazde se musel spustit a inicializovat znovu. S kolegou jsme vyhodnotili ze bysme mohli usetrit dost procesoroveho kdybysme pri kazdem spusteni toho programu nevytvareli binarni vyhledavaci strom ale meli ho uz pripraveny behem kompilace.
To se dělalo přes generátory kódu. Prostě sis ten strom převedl do C++ ekvivalentního kodu a přeložíl.
Hodně věcí dnes takto dělat nemusím , což je super
Já sem jen trochu zmaten z toho článku stále. Na začátku se ukáže problém s funkcí, která nejde přeložit. Ale už se neukáže řešen pro tenhle problém, ale pro jiný. Asi je to triviální, ale nemohl byste tedy hodit odkaz na řešení toho prvního problémku?
Přímo ten kód takhle nejde napsat, ale celá pointa je v tom, že jsem schopen nechat vygenerovat všechny varianty pro i, to mi překladač připraví a ja pak podle hodnoty vyberu adekvátní variantu. Zde je ukázka pro 10 variant pro hodnoty 0 až 9.
template<int i> struct NejakaStruktura; auto funkce_ktera_nejde_prelozit(int v) { return number_to_constant<0,9>(v, [](auto x) { if constexpr(x.valid) { NejakaStruktura<x.value> a; //... pracuj s a ... return //vysledek } }); }
Zajímavé, článek hovoří o konverzi čísla z run-time do compile-time, ale demonstrační příklad v CompilerExploreru kompilátor bohužel zoptimalizuje. Doporučuji změnit z
int main() { int index = 1; MyVariant v1 = create_variant_by_index(index); std::cout << std::holds_alternative<std::string>(v1) << std::endl; return 0; }
na
int main(int argc, char**) { int index = argc; MyVariant v1 = create_variant_by_index(index); std::cout << std::holds_alternative<std::string>(v1) << std::endl; return 0; }
aby hodnota přišla skutečně z run-time. Výsledný assembler je delší.
S pozdravem,
Marek.
Tak jsem si s tím trochu pohrál a dostal jsem se na 97 řádků assembleru (tak, jak to vypisuje CompilerExplorer) versus Novačisko 109 až 122. Použil jsem old-school techniku rekurzivní šablony třídy s explicitní specializací k zastavení rekurze. Nevyrábím jump-table, místo toho vyrábím dlouhý řetěz if-else-if-else-if-else. Nepředávám lambdu, pouze její typ, předpokládám totiž, že lambda bude state-less. Na neplatný index testuji mnohem dříve.
Moje varianta je lepší v tom, že je toho méně kódu. Jednodušší, čitelnější, pochopitelnější, udržovatelnější kód. Bude fungovat i se staršími kompilery, jako je třeba C++98.
Moje varianta je horší v tom, že je to run-time if-else-if-else-if-else, to zabírá místo v binárce a spotřebovává run-time čas. Kdežto jump-table spotřebuje méně run-time času a zdá se (překvapivě), že zabírá více místa v binárce. Možná bude všechno jinak, když nebudou varianty pouze 4 ale třeba 40, 400, apod?
https://godbolt.org/z/ezs88r5z6
Marek.
Jo, ten rekurzivní systém funguje taky, ale mrzelo mě, že některé překladače (clang) nepochopil, že jde o výběr alternativy, a překládal to tupě i na -O3. Pro 64 variant to tedy vypadalo takto (rozbalení rekurze)
if (x == 1) return fn<1>();
if (x == 2) return fn<2>();
if (x == 3) return fn<3>();
if (x == 4) return fn<4>();
if (x == 5) return fn<5>();
...
Proto jsem zkusil variantu přes jump-table.
GCC v některých situacích byl schopen pochopit, že jde o výběr variant a tu jump table tam udělal. Ale jen v některých situacích
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 51 216×
Přečteno 24 042×
Přečteno 22 910×
Přečteno 21 102×
Přečteno 17 830×