Dlaczego < = jest wolniejszy niż

Czytam slajdy łamanie limitu prędkości Javascript z V8 , i jest przykład podobny do poniższego kodu. Nie mogę zrozumieć, dlaczego <= jest wolniejszy niż < w tym przypadku, czy ktoś może to wyjaśnić? Wszelkie komentarze są mile widziane.

Powoli:

this.isPrimeDivisible = function(candidate) {
    for (var i = 1; i <= this.prime_count; ++i) {
        if (candidate % this.primes[i] == 0) return true;
    }
    return false;
} 

(podpowiedź: primes jest tablicą długości prime_count)

Szybciej:

this.isPrimeDivisible = function(candidate) {
    for (var i = 1; i < this.prime_count; ++i) {
        if (candidate % this.primes[i] == 0) return true;
    }
    return false;
} 

[Więcej informacji] Poprawa prędkości jest znacząca, w moim teście środowiska lokalnego, wyniki są następujące:

V8 version 7.3.0 (candidate) 

Powoli:

 time d8 prime.js
 287107
 12.71 user 
 0.05 system 
 0:12.84 elapsed 

Szybciej:

time d8 prime.js
287107
1.82 user 
0.01 system 
0:01.84 elapsed
 167
Author: Peeyush Kushwaha, 2018-12-06

4 answers

Pracuję nad V8 w Google i chciałem zapewnić dodatkowy wgląd w istniejące odpowiedzi i komentarze.

Dla odniesienia, oto pełny przykład kodu z slajdy :

var iterations = 25000;

function Primes() {
  this.prime_count = 0;
  this.primes = new Array(iterations);
  this.getPrimeCount = function() { return this.prime_count; }
  this.getPrime = function(i) { return this.primes[i]; }
  this.addPrime = function(i) {
    this.primes[this.prime_count++] = i;
  }
  this.isPrimeDivisible = function(candidate) {
    for (var i = 1; i <= this.prime_count; ++i) {
      if ((candidate % this.primes[i]) == 0) return true;
    }
    return false;
  }
};

function main() {
  var p = new Primes();
  var c = 1;
  while (p.getPrimeCount() < iterations) {
    if (!p.isPrimeDivisible(c)) {
      p.addPrime(c);
    }
    c++;
  }
  console.log(p.getPrime(p.getPrimeCount() - 1));
}

main();

Przede wszystkim różnica w wydajności nie ma nic wspólnego bezpośrednio z operatorami < i <=. Więc proszę nie skakać przez obręcze tylko po to, aby uniknąć <= w kodzie, ponieważ czytasz na Stack Overflow, że jest powolny - - - to nie jest!


Po drugie, ludzie zwrócili uwagę, że tablica jest "holey". Nie było to jasne z fragmentu kodu w poście OP, ale jest to jasne, gdy spojrzysz na kod inicjalizujący this.primes:

this.primes = new Array(iterations);

To powoduje, że tablica z a HOLEY rodzaj elementów W V8, nawet jeśli tablica kończy się całkowicie wypełnione/spakowane/przylegające. Ogólnie operacje na tablicach holey ' a są wolniejsze niż operacje na tablicach spakowanych, ale w tym przypadku różnica jest znikoma: wynosi do 1 dodatkowego smi (small integer ) sprawdzamy (aby zabezpieczyć się przed dziurami) za każdym razem, gdy uderzymy this.primes[i] w pętlę wewnątrz isPrimeDivisible. Nic wielkiego!

TL;DR tablica będąca HOLEY nie jest tu problemem.


Inni zwrócili uwagę, że kod odczytuje się poza granicami. Ogólnie zaleca się, aby unikać czytania poza długością tablic , a w tym przypadku rzeczywiście uniknęłoby to ogromnego spadku wydajności. Ale dlaczego? V8 poradzi sobie z niektórymi z tych scenariuszy poza granicami, które mają tylko niewielki wpływ na wydajność.Co jest takiego specjalnego w tej konkretnej sprawie?

Wynik odczytu poza granicami this.primes[i] jest undefined w tej linijce:

if ((candidate % this.primes[i]) == 0) return true;

I to prowadzi nas do prawdziwego problemu : operator % jest teraz używany z nie-całkowitymi operandami!

  • integer % someOtherInteger mogą być bardzo wydajnie obliczane; silniki JavaScript mogą produkować wysoce zoptymalizowany kod maszynowy do tego case.

  • integer % undefined z drugiej strony jest o wiele mniej efektywna Float64Mod, ponieważ undefined jest reprezentowana jako Podwójna.

Fragment kodu można rzeczywiście poprawić zmieniając <= na < w tej linii:

for (var i = 1; i <= this.prime_count; ++i) {

...nie dlatego, że <= jest w jakiś sposób operatorem nadrzędnym niż <, ale tylko dlatego, że pozwala to uniknąć przekroczenia granic odczytanych w tym konkretnym przypadku.

 133
Author: Mathias Bynens,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-12-07 12:31:32

Inne odpowiedzi i komentarze wspominają, że różnica między obiema pętlami polega na tym, że pierwsza wykonuje jedną iterację więcej niż druga. Jest to prawdą, ale w tablicy, która rośnie do 25 000 elementów, jedna iteracja mniej więcej zrobi tylko niewielką różnicę. Jeśli przyjmiemy, że średnia długość w miarę wzrostu wynosi 12 500, to różnica, jakiej możemy się spodziewać, powinna wynosić około 1/12 500 lub tylko 0,008%.

Różnica w wydajności jest tutaj znacznie większa niż można by wytłumaczyć tą jedną dodatkową iteracją, a problem jest wyjaśniony pod koniec prezentacji.

this.primes jest ciągłą tablicą (każdy element posiada wartość), a elementami są wszystkie liczby.

Silnik JavaScript może zoptymalizować taką tablicę, aby była prostą tablicą liczb rzeczywistych, zamiast tablicy obiektów, które zawierają liczby, ale mogą zawierać inne wartości lub nie zawierają wartości. Pierwszy format jest znacznie szybszy: zajmuje mniej kodu, a tablica jest znacznie mniejsza, więc lepiej zmieści się w pamięci podręcznej. Istnieją jednak pewne warunki, które mogą uniemożliwić użycie tego zoptymalizowanego formatu.

Jednym z warunków jest brak niektórych elementów tablicy. Na przykład:

let array = [];
a[0] = 10;
a[2] = 20;

Jaka jest wartość a[1]? To nie ma wartości . (Nie jest nawet poprawne mówienie, że ma wartość undefined - element tablicy zawierający wartość undefined różni się od elementu tablicy, którego w ogóle nie ma.)

Tam nie jest to sposób na reprezentowanie tego tylko liczb, więc silnik JavaScript jest zmuszony do korzystania z mniej zoptymalizowanego formatu. Jeśli a[1] zawierała wartość liczbową, podobnie jak pozostałe dwa elementy, tablica mogła zostać zoptymalizowana tylko do tablicy liczb.

Innym powodem wymuszenia użycia tablicy do odoptymizowanego formatu może być próba uzyskania dostępu do elementu poza granicami tablicy, jak opisano w prezentacji.

Pierwsza pętla z <= próbuje odczytuje element znajdujący się poza końcem tablicy. Algorytm nadal działa poprawnie, ponieważ w ostatniej dodatkowej iteracji:

  • this.primes[i] ocenia na undefined, ponieważ i jest poza końcem tablicy.
  • candidate % undefined (dla dowolnej wartości candidate) oblicza się na NaN.
  • NaN == 0 ocenia na false.
  • dlatego {[15] } nie jest wykonywany.

Więc to tak, jakby dodatkowa iteracja nigdy nie miała miejsca - nie ma to wpływu na resztę logiki. Kod produkuje te same wynik taki, jak bez dodatkowej iteracji.

Ale aby się tam dostać, próbowano odczytać nieistniejący element za końcem tablicy. Wymusza to optymalizację tablicy-lub przynajmniej zrobiła to w czasie tej rozmowy.

Druga pętla z < odczytuje tylko elementy, które istnieją w tablicy, więc pozwala na zoptymalizowaną tablicę i Kod.

Problem jest opisany w strony 90-91 rozmowy, z powiązaną dyskusją na stronach przed i po to.

Zdarzyło mi się wziąć udział w tej Prezentacji Google I/O i porozmawiać z prelegentem (jednym z autorów V8) później. Używałem techniki w moim własnym kodzie, która polegała na odczytywaniu końca tablicy jako błędnej (z perspektywy czasu) próby optymalizacji jednej konkretnej sytuacji. Potwierdził, że jeśli spróbujesz nawet odczytać poza końcem tablicy, uniemożliwi to użycie prostego zoptymalizowanego formatu.

Jeśli to, co autor V8 powiedział jest jeszcze prawda, wtedy odczytanie końca tablicy uniemożliwiłoby jej optymalizację i musiałaby powrócić do wolniejszego formatu.

Teraz jest możliwe, że V8 został ulepszony w międzyczasie, aby efektywnie obsłużyć tę sprawę, lub że inne silniki JavaScript obsługują ją inaczej. Nie wiem w ten czy inny sposób, ale o tej deoptymizacji chodziło w prezentacji.

 226
Author: Michael Geary,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-12-06 22:16:00

TL; DR wolniejsza pętla wynika z dostępu do tablicy 'out-of-bounds', która albo wymusza na silniku rekompilację funkcji z mniejszą lub nawet bez optymalizacji, albo nie kompiluje funkcji z żadną z tych optymalizacji na początku (jeśli kompilator (JIT-)wykrył / podejrzewał ten warunek przed pierwszą kompilacją 'version'), przeczytaj poniżej dlaczego;


Ktoś po prostu ma to powiedzieć (zupełnie zdumiony nikt jeszcze nie zrobił):
There used to be a time kiedy fragment OP byłby de facto przykładem w książce dla początkujących programistów, mającej na celu zarys/podkreślenie ,że "tablice" w javascript są indeksowane od 0, a nie 1, i jako taki być używany jako przykład częstego "błędu początkujących" (nie podoba Ci się, jak uniknąłem wyrażenia "błąd programowania' ;)): dostęp do tablicy poza granicami .

Przykład 1:
a Dense Array (jest ciągłym (czyli bez odstępów między indeksami) a właściwie elementem przy każdym indeksie) 5 elementów za pomocą Indeksowanie oparte na 0 (zawsze w ES262).

var arr_five_char=['a', 'b', 'c', 'd', 'e']; // arr_five_char.length === 5
//  indexes are:    0 ,  1 ,  2 ,  3 ,  4    // there is NO index number 5



Nie mówimy więc tak naprawdę o różnicy wydajności między < a <= (lub "jedną dodatkową iteracją"), ale mówimy o:
'dlaczego poprawny fragment (b) działa szybciej niż błędny fragment (a)'?

Odpowiedź jest 2-krotna (chociaż z perspektywy implementatora języka ES262 obie są formami optymalizacji):

  1. reprezentacja danych: jak reprezentuj / przechowuj tablicę wewnętrznie w pamięci (obiekt, hashmap, 'prawdziwa' tablica numeryczna, itd.)
  2. [49]}Functional Machine-code: jak skompilować kod, który uzyskuje dostęp/obsługuje (czyta/modyfikuje) te 'tablice' [50]}

Pozycja 1 jest wystarczająco (i poprawnie IMHO) wyjaśniona przez zaakceptowaną odpowiedź , ale to wydaje tylko 2 słowa ("kod") na pozycja 2: Kompilacja .

Dokładniej: JIT-Kompilacja i jeszcze ważniejsze JIT-RE-Kompilacja !

Specyfikacja języka jest w zasadzie tylko opisem zestawu algorytmów ("kroki do wykonania, aby osiągnąć określony wynik końcowy"). Co, jak się okazuje, jest bardzo pięknym sposobem na opisanie języka. I to pozostawia rzeczywistą metodę, że silnik używa do osiągnięcia określonych wyników otwarte dla implementatorów, dając wystarczającą możliwość wymyślić bardziej wydajne sposoby do uzyskania zdefiniowanych wyników. Silnik zgodny ze specyfikacją powinien dać wyniki zgodne ze specyfikacją dla każdego zdefiniowanego wejścia.

Teraz, wraz ze wzrostem kodu/bibliotek/użycia javascript i pamiętaniem, ile zasobów (czasu/pamięci/itp.) używa "prawdziwy" kompilator, jasne jest, że nie możemy sprawić, by użytkownicy odwiedzający stronę internetową czekali tak długo (i wymagali od nich tyle zasobów).

Wyobraź sobie następującą prostą funkcję:

function sum(arr){
  var r=0, i=0;
  for(;i<arr.length;) r+=arr[i++];
  return r;
}

Doskonale jasne, prawda? Nie wymaga dodatkowych wyjaśnień, prawda? Return-type to Number, prawda?
Cóż.. Nie, Nie i nie... To zależy od tego, jaki argument PRZEKAZUJESZ do parametru funkcji arr...

sum('abcde');   // String('0abcde')
sum([1,2,3]);   // Number(6)
sum([1,,3]);    // Number(NaN)
sum(['1',,3]);  // String('01undefined3')
sum([1,,'3']);  // String('NaN3')
sum([1,2,{valueOf:function(){return this.val}, val:6}]);  // Number(9)
var val=5; sum([1,2,{valueOf:function(){return val}}]);   // Number(8)
Widzisz problem ? Następnie należy wziąć pod uwagę, że jest to zaledwie skrobanie ogromne możliwe permutacje... Nie wiemy nawet, jakiego typu funkcja zwraca, dopóki nie skończymy...

Wyobraź sobie teraz tę samą funkcję - kod faktycznie używany na różnych typach, a nawet wariantach wejściowych, zarówno całkowicie dosłownie (w kodzie źródłowym) opisany i dynamicznie generowane 'tablice' w programie..

Tak więc, jeśli kompilujesz funkcję sum tylko raz, to jedyny sposób, który zawsze zwraca wynik zdefiniowany przez specyfikację dla dowolnego i wszystkich typów danych wejściowych, to, oczywiście, tylko wykonując wszystkie określone przez specyfikację kroki główne i podrzędne, może zagwarantować wyniki zgodne ze specyfikacją(jak nienazwana przeglądarka pre-y2k). Nie ma optymalizacji (bo nie ma założeń) i pozostaje martwy wolno interpretowany język skryptowy.

JIT-Kompilacja (JIT jak tylko w Czas) jest obecnie popularnym rozwiązaniem.

Więc zaczynasz kompilować funkcję używając założeń co do tego, co robi, zwraca i akceptuje.
można wymyślić kontrole tak proste, jak to możliwe, aby wykryć, czy funkcja może zacząć zwracać wyniki niezgodne ze specyfikacją (na przykład dlatego, że otrzymuje nieoczekiwane dane wejściowe). Następnie wyrzuć poprzedni skompilowany wynik i przekompiluj do czegoś bardziej rozbudowanego, zdecyduj, co zrobić z częściowym wynikiem, który już masz (czy jest poprawny zaufane lub obliczyć ponownie, aby mieć pewność), powiązać funkcję z powrotem do programu i spróbować ponownie. Ostatecznie powrót do stopniowej interpretacji skryptu jak w spec.

To wszystko wymaga czasu!

Wszystkie przeglądarki działają na swoich silnikach, dla każdej pod-wersji zobaczysz, że wszystko się poprawi i cofnie. Struny były w pewnym momencie historii naprawdę niezmiennymi strunami (stąd array.łączenie było szybsze niż łączenie strun), teraz używamy lin (lub podobnych), które łagodzą problem. Oba zwracają wyniki zgodne ze specyfikacją i to się liczy!

W skrócie: tylko dlatego, że semantyka języka javascript często nas wspiera (jak w przypadku tego cichego błędu w przykładzie OP), nie oznacza, że 'głupie' błędy zwiększają nasze szanse na to, że kompilator wypluwa szybki kod maszynowy. Zakłada, że napisaliśmy' zwykle 'poprawne instrukcje: obecna mantra, którą my 'użytkownicy' (języka programowania) musimy mieć to: pomóż kompilatorowi, opisz czego chcemy, favor common idioms (take hints from asm.js dla podstawowego zrozumienia, jakie przeglądarki mogą próbować optymalizować i dlaczego).

Z tego powodu mówienie o wydajności jest zarówno ważne, jak i moje pole (i z powodu wspomnianego pola kopalni naprawdę chcę zakończyć wskazaniem (i cytowaniem) jakiegoś istotnego materiału: {25]}

Dostęp do nieistniejących właściwości obiektu i poza granicami elementów tablicy Zwraca wartość undefined zamiast wywoływać wyjątek. Te dynamiczne funkcje sprawiają, że programowanie w JavaScript jest wygodne, ale również utrudniają kompilację JavaScript do wydajnego kodu maszynowego.

...

Ważną przesłanką efektywnej optymalizacji JIT jest to, że programiści używają dynamicznych funkcji JavaScript w sposób systematyczny. Na przykład Kompilatory JIT wykorzystują fakt, że właściwości obiektu są często dodawane do obiektu danego typu w określonej kolejności lub że dostęp do tablicy poza granicami występuje rzadko. Kompilatory JIT wykorzystaj te założenia regularności do generowania wydajnego kodu maszynowego w czasie wykonywania. Jeśli blok kodu spełnia założenia, silnik JavaScript wykonuje wydajny, wygenerowany kod maszynowy. W przeciwnym razie silnik musi powrócić do wolniejszego kodu lub do interpretacji programu.

Źródło:
"JITProf: Pinpointing JIT-nieprzyjazny kod JavaScript"
Autor: Liang Gong, Michael Pradel, Koushik Sen.
http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf

ASM.JS (również nie lubi off bound array access):

Ahead-Of-Time Compilation

Ponieważ asm.js jest ścisłym podzbiorem JavaScript, ta specyfikacja definiuje tylko logikę walidacji-semantyka wykonania jest po prostu semantyką JavaScript. Jednak zatwierdzony asm.JS jest przystosowany do kompilacji ahead-of-time (AOT). Ponadto kod generowany przez AOT kompilator może być dość wydajny, wyposażony w:

  • nieokreślone reprezentacje liczb całkowitych i zmiennoprzecinkowych;
  • brak kontroli typu runtime;
  • brak zbierania śmieci; i
  • wydajne ładunki i magazyny (ze strategiami wdrażania różniącymi się w zależności od platformy).

Kod, który nie poprawi poprawności, musi wrócić do wykonywania tradycyjnymi metodami, np. interpretacją i / lub just-In-time (JIT) kompilacja.

Http://asmjs.org/spec/latest/

I wreszcie https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/
czy istnieje mała podsekcja o wewnętrznej poprawie wydajności silnika podczas usuwania granic-sprawdź(podczas gdy tylko podnoszenie granic-sprawdź poza pętlą już miał poprawę o 40%).



EDIT:
zauważ, że wiele źródła mówią o różnych poziomach rekompilacji JIT aż do interpretacji.

przykład teoretyczny na podstawie powyższych informacji, dotyczący fragmentu OP:

  • wezwanie do isPrimeDivisible
  • Isprimedivisible (Isprimedivisible) - kompilator Isprimedivisible z ogólnymi założeniami (np. bez dostępu poza granicami).]}
  • Do work
  • BAM, nagle array accesses out of bounds (right at the end).
  • bzdura, mówi silnik, przekompilujmy to isPrimeDivisible w tym przykładzie silnik nie próbuje dowiedzieć się, czy może ponownie użyć bieżącego wyniku częściowego, więc
  • Przekomputować całą pracę używając wolniejszej funkcji (miejmy nadzieję, że zakończy się, w przeciwnym razie Powtórz i tym razem po prostu zinterpretuj kod).
  • Return result

Stąd czas był:
Pierwsze uruchomienie (nieudane na końcu) + wykonywanie całej pracy od nowa przy użyciu wolniejszego kodu maszynowego dla każdej iteracji + rekompilacja itp.. wyraźnie trwa >2 razy dłużej w tym teoretycznym przykładzie !



Edytuj 2: (zastrzeżenie: domysły oparte na faktach poniżej)
Im więcej o tym myślę, tym bardziej myślę, że ta odpowiedź może faktycznie wyjaśnić bardziej dominującą przyczynę tej "kary" na błędnym urywku a (lub premii za wydajność na urywku b, w zależności od tego, jak o tym myślisz), dokładnie dlaczego jestem adamentem w nazywaniu go (urywku a) błędem programistycznym: {]} [[22]}to dość kuszące, aby Załóżmy, że this.primes jest "gęstą tablicą" czystą liczbową, która była albo

  • Hard-coded dosłowny w kodzie źródłowym (znany jako doskonały kandydat do stania się 'rzeczywistą' tablicą, ponieważ wszystko jest już znane kompilatorowi przed compile-time) lub
  • najprawdopodobniej wygenerowana za pomocą funkcji numerycznej wypełniającej przedrostek (new Array(/*size value*/)) w rosnącym porządku sekwencyjnym (kolejny znany od dawna kandydat do stania się 'rzeczywistą' tablicą).

Wiemy również, że primes długość tablicy jest buforowana jako prime_count ! (wskazując na jego intencję i stały rozmiar).

Wiemy również, że większość silników początkowo przekazuje Tablice jako copy-on-modify (w razie potrzeby), co sprawia, że obsługa ich jest znacznie szybsza (jeśli ich nie zmienisz).

Jest zatem rozsądne założenie, że tablica primes jest najprawdopodobniej już wewnętrznie zoptymalizowaną tablicą, która nie jest zmieniana po utworzeniu (proste do poznania dla kompilatora, jeśli nie ma kodu modyfikującego tablicę po utworzeniu) i dlatego jest już (jeśli dotyczy silnika) przechowywany w zoptymalizowany sposób, prawie jakby to było Typed Array.

Jak starałem się wyjaśnić za pomocą mojego przykładu funkcji sum, argumenty, które zostaną przekazane, mają wpływ na to, co faktycznie musi się wydarzyć i jak dany kod jest kompilowany do kodu maszynowego. Przekazywanie String do funkcji sum nie powinno zmieniać ciągu znaków, ale zmieniać sposób kompilacji funkcji JIT! Passing an Array to sum powinno skompilować inną (być może nawet dodatkową dla tego typu lub 'kształtu', jak to nazywają, obiektu, który został przekazany) wersję kodu maszynowego.

Jak się wydaje nieco bonkus przekonwertować tablicę Typed_Array-like primes w locie do something_else, podczas gdy kompilator wie, że ta funkcja nie będzie nawet go modyfikować!

Zgodnie z tymi założeniami pozostawia 2 opcje:

  1. Skompiluj jako Number-cruncher zakładając, że nie ma poza granicami, Uruchom w przeciwieństwie do poprzednich wersji, nie jest to możliwe.]}
  2. kompilator już wykrył(lub podejrzewa?) z bound acces up-front i funkcja została skompilowana JIT tak, jakby przekazany argument był rzadkim obiektem, co skutkowało wolniejszym funkcjonalnym kodem maszynowym (ponieważ miałby więcej sprawdzeń/konwersji/przymusu itp.). Innymi słowy: funkcja nigdy nie była przystosowana do pewnych optymalizacji, została skompilowana tak, jakby otrzymała argument 'sparse array' (- like).

Teraz naprawdę zastanawiam się, który z tych 2 to jest!

 19
Author: GitaarLAB,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-12-07 06:24:24

Aby dodać trochę naukowych do niego, Oto jsperf

Https://jsperf.com/ints-values-in-out-of-array-bounds

Testuje przypadek kontrolny tablicy wypełnionej ints i pętli wykonującej arytmetykę modularną, pozostając w granicach. Ma 5 przypadków testowych:

  • 1. Looping out of bounds
  • 2. Tablice Holey
  • 3. Arytmetyka modularna wobec Nan
  • 4. Całkowicie niezdefiniowane wartości
  • 5. Korzystanie z new Array()

Pokazuje, że pierwsze 4 przypadki są naprawdę złe dla wydajności. Zapętlenie poza granicami jest nieco lepsze niż pozostałe 3, ale wszystkie 4 są mniej więcej 98% wolniejsze niż w najlepszym przypadku.
Przypadek new Array() jest prawie tak dobry jak tablica raw, tylko kilka procent wolniejszy.

 6
Author: Nathan Adams,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-12-07 18:29:40