Czy rodzina "* apply " naprawdę nie jest wektoryzowana?
Więc jesteśmy przyzwyczajeni do mówienia każdemu nowemu użytkownikowi, że "apply
nie jest wektoryzowany, sprawdź Patrick Burns R Inferno Circle 4 " który mówi (cytuję):
Powszechnym odruchem jest użycie funkcji w rodzinie apply. to nie jest wektoryzacja, to ukrywanie pętli. Funkcja apply ma pętlę for w jego definicja. Funkcja lapply zakopuje pętlę, ale wykonanie czasy wydają się być mniej więcej równe wyraźnemu dla pętla.
Rzeczywiście, szybkie spojrzenie na apply
kod źródłowy ujawnia pętlę:
grep("for", capture.output(getAnywhere("apply")), value = TRUE)
## [1] " for (i in 1L:d2) {" " else for (i in 1L:d2) {"
Jak na razie Ok, ale spojrzenie na lapply
lub vapply
faktycznie odsłania zupełnie inny obraz:
lapply
## function (X, FUN, ...)
## {
## FUN <- match.fun(FUN)
## if (!is.vector(X) || is.object(X))
## X <- as.list(X)
## .Internal(lapply(X, FUN))
## }
## <bytecode: 0x000000000284b618>
## <environment: namespace:base>
Więc najwyraźniej nie ma tam pętli R for
, a raczej wywołują wewnętrzną funkcję zapisu C.
Szybkie spojrzenie w królika dziura pokazuje prawie to samo zdjęcie
Ponadto Weźmy na przykład funkcję colMeans
, który nigdy nie został oskarżony o brak wektoryzacji
colMeans
# function (x, na.rm = FALSE, dims = 1L)
# {
# if (is.data.frame(x))
# x <- as.matrix(x)
# if (!is.array(x) || length(dn <- dim(x)) < 2L)
# stop("'x' must be an array of at least two dimensions")
# if (dims < 1L || dims > length(dn) - 1L)
# stop("invalid 'dims'")
# n <- prod(dn[1L:dims])
# dn <- dn[-(1L:dims)]
# z <- if (is.complex(x))
# .Internal(colMeans(Re(x), n, prod(dn), na.rm)) + (0+1i) *
# .Internal(colMeans(Im(x), n, prod(dn), na.rm))
# else .Internal(colMeans(x, n, prod(dn), na.rm))
# if (length(dn) > 1L) {
# dim(z) <- dn
# dimnames(z) <- dimnames(x)[-(1L:dims)]
# }
# else names(z) <- dimnames(x)[[dims + 1]]
# z
# }
# <bytecode: 0x0000000008f89d20>
# <environment: namespace:base>
Co? To również po prostu nazywa .Internal(colMeans(...
, które możemy również znaleźć w króliczej nory. Czym to się różni od .Internal(lapply(..
?
W rzeczywistości szybki benchmark pokazuje, że sapply
Działa nie gorzej niż colMeans
i znacznie lepiej niż pętla for
dla dużego zbioru danych
m <- as.data.frame(matrix(1:1e7, ncol = 1e5))
system.time(colMeans(m))
# user system elapsed
# 1.69 0.03 1.73
system.time(sapply(m, mean))
# user system elapsed
# 1.50 0.03 1.60
system.time(apply(m, 2, mean))
# user system elapsed
# 3.84 0.03 3.90
system.time(for(i in 1:ncol(m)) mean(m[, i]))
# user system elapsed
# 13.78 0.01 13.93
Innymi słowy, czy poprawne jest stwierdzenie, że lapply
i vapply
są faktycznie wektoryzowane (w porównaniu do apply
, która jest pętlą for
, która również telefony lapply
) i co tak naprawdę chciał powiedzieć Patrick Burns?
4 answers
Po pierwsze, w twoim przykładzie wykonujesz testy na " danych.frame", co nie jest sprawiedliwe dla colMeans
, apply
i "[.data.frame"
ponieważ mają nad głową:
system.time(as.matrix(m)) #called by `colMeans` and `apply`
# user system elapsed
# 1.03 0.00 1.05
system.time(for(i in 1:ncol(m)) m[, i]) #in the `for` loop
# user system elapsed
# 12.93 0.01 13.07
Na matrycy obraz jest nieco inny:
mm = as.matrix(m)
system.time(colMeans(mm))
# user system elapsed
# 0.01 0.00 0.01
system.time(apply(mm, 2, mean))
# user system elapsed
# 1.48 0.03 1.53
system.time(for(i in 1:ncol(mm)) mean(mm[, i]))
# user system elapsed
# 1.22 0.00 1.21
Regading głównej części pytania, głównej różnicy między lapply
/mapply
/etc i proste pętle R to miejsce, w którym odbywa się pętla. Jak zauważa Roland, obie pętle C i R muszą ocenić funkcję R w każdej iteracji, która jest najbardziej kosztowna. The really szybkie funkcje C to te, które robią wszystko w C, więc chyba o to chodzi?
Przykład, w którym znajdujemy średnią w każdym z elementów "listy":
(EDIT May 11 '16: uważam, że przykład ze znalezieniem "średniej" nie jest dobrym ustawieniem dla różnic między iteracyjnie ocenianiem funkcji R A skompilowanym kodem, (1) ze względu na specyfikę algorytmu średniej R na "numerycznej"s nad prostą sum(x) / length(x)
i (2) powinien bardziej sensowne jest testowanie na"liście" z length(x) >> lengths(x)
. Tak więc przykład" mean " zostaje przeniesiony na koniec i zastąpiony innym.)
Jako prosty przykład możemy rozważyć znalezienie przeciwieństwa każdego length == 1
elementu "listy":
W pliku tmp.c
:
#include <R.h>
#define USE_RINTERNALS
#include <Rinternals.h>
#include <Rdefines.h>
/* call a C function inside another */
double oppC(double x) { return(ISNAN(x) ? NA_REAL : -x); }
SEXP sapply_oppC(SEXP x)
{
SEXP ans = PROTECT(allocVector(REALSXP, LENGTH(x)));
for(int i = 0; i < LENGTH(x); i++)
REAL(ans)[i] = oppC(REAL(VECTOR_ELT(x, i))[0]);
UNPROTECT(1);
return(ans);
}
/* call an R function inside a C function;
* will be used with 'f' as a closure and as a builtin */
SEXP sapply_oppR(SEXP x, SEXP f)
{
SEXP call = PROTECT(allocVector(LANGSXP, 2));
SETCAR(call, install(CHAR(STRING_ELT(f, 0))));
SEXP ans = PROTECT(allocVector(REALSXP, LENGTH(x)));
for(int i = 0; i < LENGTH(x); i++) {
SETCADR(call, VECTOR_ELT(x, i));
REAL(ans)[i] = REAL(eval(call, R_GlobalEnv))[0];
}
UNPROTECT(2);
return(ans);
}
I na stronie R:
system("R CMD SHLIB /home/~/tmp.c")
dyn.load("/home/~/tmp.so")
Z danymi:
set.seed(007)
myls = rep_len(as.list(c(NA, runif(3))), 1e7)
#a closure wrapper of `-`
oppR = function(x) -x
for_oppR = compiler::cmpfun(function(x, f)
{
f = match.fun(f)
ans = numeric(length(x))
for(i in seq_along(x)) ans[[i]] = f(x[[i]])
return(ans)
})
Benchmarking:
#call a C function iteratively
system.time({ sapplyC = .Call("sapply_oppC", myls) })
# user system elapsed
# 0.048 0.000 0.047
#evaluate an R closure iteratively
system.time({ sapplyRC = .Call("sapply_oppR", myls, "oppR") })
# user system elapsed
# 3.348 0.000 3.358
#evaluate an R builtin iteratively
system.time({ sapplyRCprim = .Call("sapply_oppR", myls, "-") })
# user system elapsed
# 0.652 0.000 0.653
#loop with a R closure
system.time({ forR = for_oppR(myls, "oppR") })
# user system elapsed
# 4.396 0.000 4.409
#loop with an R builtin
system.time({ forRprim = for_oppR(myls, "-") })
# user system elapsed
# 1.908 0.000 1.913
#for reference and testing
system.time({ sapplyR = unlist(lapply(myls, oppR)) })
# user system elapsed
# 7.080 0.068 7.170
system.time({ sapplyRprim = unlist(lapply(myls, `-`)) })
# user system elapsed
# 3.524 0.064 3.598
all.equal(sapplyR, sapplyRprim)
#[1] TRUE
all.equal(sapplyR, sapplyC)
#[1] TRUE
all.equal(sapplyR, sapplyRC)
#[1] TRUE
all.equal(sapplyR, sapplyRCprim)
#[1] TRUE
all.equal(sapplyR, forR)
#[1] TRUE
all.equal(sapplyR, forRprim)
#[1] TRUE
(podąża za oryginalnym przykładem znalezienia średniej):
#all computations in C
all_C = inline::cfunction(sig = c(R_ls = "list"), body = '
SEXP tmp, ans;
PROTECT(ans = allocVector(REALSXP, LENGTH(R_ls)));
double *ptmp, *pans = REAL(ans);
for(int i = 0; i < LENGTH(R_ls); i++) {
pans[i] = 0.0;
PROTECT(tmp = coerceVector(VECTOR_ELT(R_ls, i), REALSXP));
ptmp = REAL(tmp);
for(int j = 0; j < LENGTH(tmp); j++) pans[i] += ptmp[j];
pans[i] /= LENGTH(tmp);
UNPROTECT(1);
}
UNPROTECT(1);
return(ans);
')
#a very simple `lapply(x, mean)`
C_and_R = inline::cfunction(sig = c(R_ls = "list"), body = '
SEXP call, ans, ret;
PROTECT(call = allocList(2));
SET_TYPEOF(call, LANGSXP);
SETCAR(call, install("mean"));
PROTECT(ans = allocVector(VECSXP, LENGTH(R_ls)));
PROTECT(ret = allocVector(REALSXP, LENGTH(ans)));
for(int i = 0; i < LENGTH(R_ls); i++) {
SETCADR(call, VECTOR_ELT(R_ls, i));
SET_VECTOR_ELT(ans, i, eval(call, R_GlobalEnv));
}
double *pret = REAL(ret);
for(int i = 0; i < LENGTH(ans); i++) pret[i] = REAL(VECTOR_ELT(ans, i))[0];
UNPROTECT(3);
return(ret);
')
R_lapply = function(x) unlist(lapply(x, mean))
R_loop = function(x)
{
ans = numeric(length(x))
for(i in seq_along(x)) ans[i] = mean(x[[i]])
return(ans)
}
R_loopcmp = compiler::cmpfun(R_loop)
set.seed(007); myls = replicate(1e4, runif(1e3), simplify = FALSE)
all.equal(all_C(myls), C_and_R(myls))
#[1] TRUE
all.equal(all_C(myls), R_lapply(myls))
#[1] TRUE
all.equal(all_C(myls), R_loop(myls))
#[1] TRUE
all.equal(all_C(myls), R_loopcmp(myls))
#[1] TRUE
microbenchmark::microbenchmark(all_C(myls),
C_and_R(myls),
R_lapply(myls),
R_loop(myls),
R_loopcmp(myls),
times = 15)
#Unit: milliseconds
# expr min lq median uq max neval
# all_C(myls) 37.29183 38.19107 38.69359 39.58083 41.3861 15
# C_and_R(myls) 117.21457 123.22044 124.58148 130.85513 169.6822 15
# R_lapply(myls) 98.48009 103.80717 106.55519 109.54890 116.3150 15
# R_loop(myls) 122.40367 130.85061 132.61378 138.53664 178.5128 15
# R_loopcmp(myls) 105.63228 111.38340 112.16781 115.68909 128.1976 15
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
2016-05-11 14:26:41
Dla mnie wektoryzacja polega przede wszystkim na tym, aby Twój kod był łatwiejszy do napisania i łatwiejszy do zrozumienia.
Celem wektoryzowanej funkcji jest wyeliminowanie księgowości związanej z pętlą for. Na przykład zamiast:
means <- numeric(length(mtcars))
for (i in seq_along(mtcars)) {
means[i] <- mean(mtcars[[i]])
}
sds <- numeric(length(mtcars))
for (i in seq_along(mtcars)) {
sds[i] <- sd(mtcars[[i]])
}
Możesz napisać:
means <- vapply(mtcars, mean, numeric(1))
sds <- vapply(mtcars, sd, numeric(1))
To ułatwia sprawdzenie, co jest takie samo (dane wejściowe), a co inne (funkcja, którą stosujesz).
Dodatkową zaletą wektoryzacji jest to, że pętla for jest często zapisywana w C, raczej niż w R. ma to istotne zalety, ale nie sądzę, że jest to kluczowa właściwość wektoryzacji. Wektoryzacja polega zasadniczo na ratowaniu mózgu, a nie na ratowaniu pracy komputera.
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
2015-03-11 12:20:51
Zgadzam się z poglądem Patricka Burnsa, że jest to raczej ukrywanie pętli , a nie wektoryzacja kodu . Oto dlaczego:
Rozważ ten C
fragment kodu:
for (int i=0; i<n; i++)
c[i] = a[i] + b[i]
to, co chcielibyśmy zrobić, jest dość jasne. Ale to, jak zadanie jest wykonywane lub jak może być wykonane, nie jest tak naprawdę. A for-loop domyślnie jest konstrukcją szeregową. Nie informuje, czy i w jaki sposób można robić rzeczy równolegle.
Najbardziej oczywistym sposobem jest to, że kod jest uruchamiany w sposób sekwencyjny. Wczytaj a[i]
i b[i]
do rejestrów, dodaj je, zapisz wynik w c[i]
i zrób to dla każdego i
.
a
i b
pod tą samą instrukcją, zamiast pojedynczo.
[[30]} chcielibyśmy wykorzystać pojedyncza Instrukcja wiele danych i wykonać równoległość poziomu danych, np. załadować 4 rzeczy na raz, dodać 4 rzeczy na raz, zapisać 4 rzeczy na raz, na przykład. A to jest wektoryzacja kodu .Zauważ, że różni się to od paralelizacji kodu - gdzie wiele obliczenia są wykonywane jednocześnie.
Byłoby wspaniale, gdyby kompilator zidentyfikował takie bloki kodu i automatycznie wektoryzował je, co jest trudnym zadaniem. Automatyczna wektoryzacja kodu to trudny temat badawczy w informatyce. Ale z czasem Kompilatory stały się w tym lepsze. Możesz sprawdzić możliwości auto wektoryzacji GNU-gcc
tutaj . Podobnie dla LLVM-clang
tutaj . I można również znajdź kilka benchmarków w ostatnim linku w porównaniu z gcc
i ICC
(kompilator Intel C++).
gcc
(jestem na v4.9
) na przykład nie wektoryzuje kodu automatycznie przy optymalizacji poziomu -O2
. Więc jeśli wykonamy kod pokazany powyżej, będzie on uruchamiany sekwencyjnie. Oto Czas dodania dwóch wektorów całkowitych o długości 500 milionów.
Musimy dodać flagę -ftree-vectorize
lub zmienić optymalizację na poziom -O3
. (Zauważ, że -O3
wykonuje inne dodatkowe optymalizacje również). Flaga -fopt-info-vec
jest przydatna, ponieważ informuje, kiedy pętla została pomyślnie wektoryzowana).
# compiling with -O2, -ftree-vectorize and -fopt-info-vec
# test.c:32:5: note: loop vectorized
# test.c:32:5: note: loop versioned for vectorization because of possible aliasing
# test.c:32:5: note: loop peeled for vectorization to enhance alignment
To mówi nam, że funkcja jest wektoryzowana. Poniżej przedstawiono timingi porównujące zarówno wersje bez wektoryzowane, jak i wektoryzowane na wektorach całkowitych o długości 500 milionów:
x = sample(100L, 500e6L, TRUE)
y = sample(100L, 500e6L, TRUE)
z = vector("integer", 500e6L) # result vector
# non-vectorised, -O2
system.time(.Call("Csum", x, y, z))
# user system elapsed
# 1.830 0.009 1.852
# vectorised using flags shown above at -O2
system.time(.Call("Csum", x, y, z))
# user system elapsed
# 0.361 0.001 0.362
# both results are checked for identicalness, returns TRUE
Tę część można bezpiecznie pominąć bez utraty ciągłości.
Kompilatory nie zawsze będą miały wystarczające informacje do wektoryzacji. Moglibyśmy użyć Specyfikacja OpenMP do programowania równoległego , która również dostarcza dyrektywę kompilatora simd do instruowania kompilatorów do wektoryzacji kodu. Ważne jest, aby upewnić się, że nie ma nakładania się pamięci, warunków wyścigu itp.. podczas ręcznego wektoryzowania kodu, w przeciwnym razie spowoduje to nieprawidłowe wyniki.
#pragma omp simd
for (i=0; i<n; i++)
c[i] = a[i] + b[i]
Robiąc to, zwracamy się do kompilatora o wektoryzację bez względu na wszystko. Będziemy musieli aktywować rozszerzenia OpenMP używając znacznika czasu kompilacji -fopenmp
. Robiąc to:
# timing with -O2 + OpenMP with simd
x = sample(100L, 500e6L, TRUE)
y = sample(100L, 500e6L, TRUE)
z = vector("integer", 500e6L) # result vector
system.time(.Call("Cvecsum", x, y, z))
# user system elapsed
# 0.360 0.001 0.360
Co jest świetne! Zostało to przetestowane z gcc v6.2.0 i llvm clang v3.9. 0 (oba zainstalowane przez homebrew, MacOS 10.12.3), oba obsługują OpenMP 4.0.
W tym sensie, nawet jeśli Strona Wikipedii dotycząca programowania tablic wspomina, że języki operujące na całych tablicach zwykle nazywają to operacjamiwektoryzowanymi , to tak naprawdę jest toukrywanie pętli IMO (chyba że jest to faktycznie wektoryzowane).
W przypadku R, nawet rowSums()
lub colSums()
kod w C nie wykorzystuje kod wektoryzacji IUC; jest to tylko pętla w C. to samo dotyczy lapply()
. W przypadku apply()
, jest w R. wszystkie z nich są zatem ukrywaniem pętli.
W skrócie, zawijanie funkcji R przez:
Wystarczy napisać for-loop W
C
!= wektoryzacja kodu.
wystarczy napisać for-loop wR
!= wektoryzacja kodu.Intel Math Kernel Library (MKL) na przykład implementuje wektoryzowane formy funkcji.
HTH
Bibliografia:
- W 2007 roku, w ramach programu "Horyzont 2020", w ramach programu "Horyzont 2020", w ramach programu "Horyzont 2020" - programu ramowego w zakresie badań naukowych i innowacji (2014-2020), zrealizowano]}
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
2017-02-22 22:44:02
Więc sumując świetne odpowiedzi/komentarze do jakiejś ogólnej odpowiedzi i podaj trochę tła: R ma 4 rodzaje pętli (W porządku nie-wektoryzowanym do wektoryzowanego )
- R
for
pętla, która wielokrotnie wywołuje funkcje R w każdej iteracji (Nie wektoryzowana )
Pętla C, która wielokrotnie wywołuje funkcje R w każdej iteracji (Nie wektoryzowana )
- pętla C wywołująca funkcję r tylko raz (nieco wektoryzowana )
- A prosta pętla C, która nie wywołuje żadnej r funkcji w ogóle i używa jej własnych skompilowanych funkcji (Wektoryzowanych)
Rodzina *apply
jest więc drugim rodzajem. Z wyjątkiem apply
, który jest bardziej pierwszego typu
Możesz to zrozumieć z komentarza w jego kodzie źródłowym
/* .Internal(lapply (X, FUN)) */
/* to jest coś specjalnego .Wewnętrzne, tak samo bezcenne argumenty. Jest to
wywołany z zamknięcia wrapper, więc X i FUN to obietnice. Zabawa musi być bezcenne do wykorzystania np. w bquote . */
Oznacza to, że lapply
kod S C akceptuje nieocenioną funkcję z R, a następnie ocenia ją w samym kodzie C. Jest to w zasadzie różnica pomiędzy lapply
s .Internal
wywołaniem
.Internal(lapply(X, FUN))
Który ma FUN
argument, który posiada funkcję R
I colMeans
.Internal
wywołanie, które nie ma } argumentu FUN
.Internal(colMeans(Re(x), n, prod(dn), na.rm))
colMeans
, W przeciwieństwie do lapply
wie dokładnie jakiej funkcji potrzebuje użyć, dlatego oblicza średnią wewnątrz kodu C.
Można wyraźnie zobaczyć proces oceny funkcji R w każdej iteracji wewnątrz lapply
kod C
for(R_xlen_t i = 0; i < n; i++) {
if (realIndx) REAL(ind)[0] = (double)(i + 1);
else INTEGER(ind)[0] = (int)(i + 1);
tmp = eval(R_fcall, rho); // <----------------------------- here it is
if (MAYBE_REFERENCED(tmp)) tmp = lazy_duplicate(tmp);
SET_VECTOR_ELT(ans, i, tmp);
}
Podsumowując wszystko, lapply
nie jest wektoryzowana, choć ma dwie możliwe przewagi nad zwykłą pętlą R for
-
Dostęp i przypisywanie w pętli wydaje się być szybsze w C (tj. w
lapply
ing a funkcja) chociaż różnica wydaje się duża, to jednak pozostajemy na poziomie mikrosekundy, a kosztowną rzeczą jest wycena funkcji R w każdej iteracji. Prosty przykład:ffR = function(x) { ans = vector("list", length(x)) for(i in seq_along(x)) ans[[i]] = x[[i]] ans } ffC = inline::cfunction(sig = c(R_x = "data.frame"), body = ' SEXP ans; PROTECT(ans = allocVector(VECSXP, LENGTH(R_x))); for(int i = 0; i < LENGTH(R_x); i++) SET_VECTOR_ELT(ans, i, VECTOR_ELT(R_x, i)); UNPROTECT(1); return(ans); ') set.seed(007) myls = replicate(1e3, runif(1e3), simplify = FALSE) mydf = as.data.frame(myls) all.equal(ffR(myls), ffC(myls)) #[1] TRUE all.equal(ffR(mydf), ffC(mydf)) #[1] TRUE microbenchmark::microbenchmark(ffR(myls), ffC(myls), ffR(mydf), ffC(mydf), times = 30) #Unit: microseconds # expr min lq median uq max neval # ffR(myls) 3933.764 3975.076 4073.540 5121.045 32956.580 30 # ffC(myls) 12.553 12.934 16.695 18.210 19.481 30 # ffR(mydf) 14799.340 15095.677 15661.889 16129.689 18439.908 30 # ffC(mydf) 12.599 13.068 15.835 18.402 20.509 30
-
Jak wspomniano przez @ Roland, uruchamia skompilowaną pętlę C, a raczej zinterpretowaną pętlę R
Chociaż podczas wektoryzacji kodu, są pewne rzeczy, które musisz wziąć pod uwagę.
- Jeśli twój zbiór danych (nazwijmy go
df
) należy do klasydata.frame
, niektóre funkcje wektorowe (takie jakcolMeans
,colSums
,rowSums
, itd.) będzie musiał najpierw przekształcić go w matrycę, po prostu dlatego, że tak zostały zaprojektowane. Oznacza to, że dla dużegodf
może to spowodować ogromne obciążenie. Chociażlapply
nie będzie musiał tego robić, ponieważ wyciąga rzeczywiste wektory zdf
(ponieważdata.frame
jest tylko listą wektorów) i dlatego, jeśli masz nie tyle kolumn, ale wiele wierszy,lapply(df, mean)
może być czasami lepszą opcją niżcolMeans(df)
. - kolejną rzeczą do zapamiętania jest to, że R ma duża różnorodność różnych typów funkcji, takich jak
.Primitive
, oraz generyczne (S3
,S4
) zobacz tutaj aby uzyskać dodatkowe informacje. Funkcja generyczna musi wykonać wysyłkę metody, która czasami jest kosztowną operacją. Na przykład,mean
jest ogólną funkcjąS3
, podczas gdysum
jestPrimitive
. Tak więc czasamilapply(df, sum)
może być bardzo wydajne w porównaniucolSums
z powodów wymienionych powyżej
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
2017-08-09 22:36:56