Можно выделить ряд алгоритмов, которые являются базовыми и лежат в основе практически каждой строчки программ, написанных на языках высокого уровня. Хорошо иметь под руками классический многотомный труд Дональда Кнута "The Art of Computer Programming", там детально разобраны многие базовые алгоритмы. Но прочесть и усвоить все — задача, требующая много усилий и времени, которая должна как-то быть мотивирована.
Многие могут предположить, что нюансы необходимо было знать 50 лет назад, а сейчас можно пользоваться готовыми пакетами и функциями и не погружаться в детали. Однако, это далеко не так. Равно как никто не отменял важность понимания представления методов хранения данных в памяти и их обработки в процессоре.
Далее разберем нюансы на примере функций сортировки. Сортировка и поиск используются максимально часто во всех манипуляциях с данными. Экономия нескольких миллисекунд на операции может приводить к суммарному сокращению часов расчета на значительных данных.
Является продолжением серии предыдущих публикаций.
Сформулируем упрощенную постановку задачи, на основе практических задач по работе с временными рядами. Исследуется набор данных, содержащих некоторое количество прецедентов, в рамках которых происходят определенные последовательности событий.
Допустим, что для алгоритмической отработки необходимо подготовить тестовый датасет, содержащий три колонки:
Используемые библиотеки
library(tidyverse)
library(data.table)
library(rTRNG)
Наиболее интересной задачей является генерация временных меток. События должны идти последовательно во времени для каждого кейса в отдельности. Сначала подготовим простую "рыбу". В частном случае мы возьмем для демонстрации малое число кейсов. В продуктиве их может быть 10^5-10^n, что определяется задачами.
Пример кода
# определим число кейсов
nn <- 100
# создаем первичный набор кейсов
records <- c("first", "one and a half", "second", "third", "fourth",
"fifth", "sixth")
# готовим два варианта для экспериментов
df <- tibble(case_id = 1:nn, recs = list(records)) %>%
unnest(recs)
dt <- as.data.table(df)[, case_id := as.numeric(case_id)]
# указание ключа приводит к физической сортировке данных
setkey(dt, case_id)
head(df, 10)
# A tibble: 10 x 2
case_id recs
<int> <chr>
1 1 first
2 1 one and a half
3 1 second
4 1 third
5 1 fourth
6 1 fifth
7 1 sixth
8 2 first
9 2 one and a half
10 2 second
Теперь приступим к интересному блоку — генерации временных меток. Для простоты задачи сведем ее к распределению долей в интервале [0; 1] в рамках каждого кейса. Перевод в реальный unixtimestamp оставим за пределами, это неинтересно. Варианты с явными циклами также за пределами. Времена исполнения приведены на условном компьютере, главное, что выполняется все на одном.
Этот вариант предлагается в большинстве случаев. А что, все просто и понятно.
Пример кода
f1 <- function(df) {
df %>%
group_by(case_id) %>%
mutate(t_idx = sort(runif(n(), 0, 1))) %>%
ungroup()
}
Получаем такие условные показатели. Наверное, неплохо. Но не забываем, что тут всего 100 кейсов.
median `itr/sec` mem_alloc
15.38ms 63.2 284.9KB
Подумаем, что можно улучшить?
Есть хорошая библиотека rTRNG. На больших объемах она дает существенное ускорение, в том числе, за счет параллельной генерации. Просто проверим:
Пример кода
f1_5 <- function(df) {
df %>%
group_by(case_id) %>%
mutate(t_idx = sort(runif_trng(n(), 0, 1))) %>%
ungroup()
}
median `itr/sec` mem_alloc
29.34ms 29.5 284.9KB
На малых объемах не получили никакого выигрыша. Это все? Конечно же нет. Мы знаем, что tidyverse медленнее data.table, попробуем применить его.
Здесь мы попробуем применить первую хитрость — отсортировать вектор времен по индексам, а потом его переприсвоить.
Пример кода
f2 <- function(dt) {
# здесь полагаемся на то, что мы заранее отсортировали уже по `case_id``
# формируем случайные числа и сортируем их по кейсам
vec <- dt[, t_idx := runif_trng(.N, 0, 1)][order(case_id, t_idx), t_idx]
# возвращаем сортированный
dt[, t_idx := vec]
}
Получается вполне неплохо, ускорение раз в 15-20 и памяти требуется почти в три раза меньше.
median `itr/sec` mem_alloc
1.69ms 554. 109KB
Останавливаемся? А почему да?
На самом деле, как только мы сваливаемся в цикл, явный, или через by, мы резко просаживаемся в производительности. Попробуем сделать все за один проход. Идея следующая — сделать композитный индекс, который позволил бы нам отсортировать все события одним махом. Используем трюк. Поскольку у нас внутри кейса все временные метки будут в диапазоне [0; 1], то мы можем разделить индекс на две части. Целочисленная часть будет содержать case_id, дробная часть — временнУю долю. Однократная сортировка одного такого индекса сохранит принадлежность строчек case_id, при этом мы одним махом отсортируем значения внутри каждого кейса
Пример кода
f3 <- function(dt) {
# делаем трюк, формируем композитный индекс из case_id, который является монотонным, и смещением по времени
# поскольку случайные числа генерятся в диапазоне [0, 1], мы их утаскиваем в дробную часть (за запятую)
# сначала просто генерируем случайные числа от 0 до 1 для каждой записи отдельно
# и масштабируем одним вектором
dt[, t_idx := sort(case_id + runif_trng(.N, 0, 1, parallelGrain = 10000L)) - case_id]
}
Запускаем и получаем выигрыш еще в 2 раза против предыдущего варианта, как по времени, так и по памяти.
median `itr/sec` mem_alloc
826.7us 1013. 54.3KB
Останавливаемся? Можно и остановиться, хотя поле для сжатия еще есть. Дело в том, что при таких малых временах исполнения накладные расходы на NSE становятся весьма ощутимыми. Если использовать прямые функции, то можно получить куда лучший результат.
Пример кода
f3_5 <- function(dt) {
set(dt, j = "t_idx",
value = sort(dt$case_id + runif(nrow(dt), 0, 1)) - dt$case_id)
}
Ускорение еще в 5 раз, памяти потребляем в 4 раза меньше
median `itr/sec` mem_alloc
161.5us 5519. 16.3KB
Соберем все вместе.
Тестируем
bench::mark(
f1(df),
f1_5(df),
f2(dt),
f3(dt),
f3_5(dt),
check = FALSE
)
expression min median `itr/sec` mem_alloc
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt>
1 f1(df) 14.3ms 15.38ms 63.2 284.9KB
2 f1_5(df) 24.43ms 29.34ms 29.5 284.9KB
3 f2(dt) 1.55ms 1.69ms 554. 109KB
4 f3(dt) 722us 826.7us 1013. 54.3KB
5 f3_5(dt) 142.5us 161.5us 5519. 16.3KB
Путем изучения принципов работы алгоритмов и пристального взгляда на задачу первичный наивный вариант удалось бесплатно улучшить по производительности в 90 раз, а потребление памяти сократить в 18 раз. Это показатели, которые слабо достижимы горизонтальным масштабированием серверов.
Усложним немного задачу. Теперь для каждой записи надо сформировать время начала и время окончания ("прием у врача"). При этом все записи должны соблюдать исходный порядок следования во времени и время окончания для каждой записи должно быть не раньше времени начала.
Количество рассматриваемых сценариев сократим, оставим только наиболее интересные. И всякие варианты с явными циклами тоже даже не упоминаем.
Первый наиболее разумный и очевидный вариант — создать метки начала, как в предыдущей задаче и потом для каждой метки поставить случайное окончание, так, чтобы соблюдались правила. Итерируем построчно, есть специфика, что долевое начало следующего кейса будет, как правило, меньше, чем начало последней операции предыдущего. Но это тоже решаемо. Код может выглядеть примерно так:
Пример кода
# Cоздание ЧЕТЫРЕХ колонок -- case_id, record, start, finish
# Все как в предыдущем, только для каждого записи finish > start
# и для двух последовательных записей 1, 2 в одном кейсе start_2 > finish_1
dt[, t_idx := NULL] # очистим хвосты предыдущего упражнения
f1 <- function(df) {
df %>%
group_by(case_id) %>%
mutate(ts_idx = sort(runif(n(), 0, 1))) %>%
ungroup() %>%
# еще раз пройдемся генератором, используя время начала следующей записи как границу
# чтобы избежать NaN при переходе между кейсами (в случае max < min),
# принудительно выставим порог 1 в таких переходах, NA в последней строке тоже заменим на 1
mutate(tf_idx = {lead(ts_idx, default = 1) %>% if_else(. > ts_idx, ., 1)}) %>%
mutate(tf_idx = map2_dbl(ts_idx, tf_idx, ~runif(1, .x, .y)))
}
В целом меньше секунды, но, очевидно, что это ОЧЕНЬ далеко от оптимальности.
median `itr/sec` mem_alloc
28.16ms 30.7 2.06MB
Идея следующая. Нам нужно получить 2 колонки, в которых значения будут отсортированы строчка за строчкой и значение первой колонки не превышает значение второй колонки для каждой строки. Но это же матрица! Матрица — это единый кусок в памяти, который сопровождается атрибутами сегментирования. Работа с ними куда быстрее классических датафреймов.
Пример кода
f2 <- function(dt){
dt[, c("ts_idx", "tf_idx") := {
# используем принцип vector recycling
x <- case_id + runif(2 * .N, 0, 1);
m <- matrix(sort(x), ncol = 2, byrow = TRUE) - case_id;
list(m[, 1], m[, 2])
}]
}
В легкую сократили время и память почти в 30 раз! Да и код стал существенно проще и прямолинейнее.
median `itr/sec` mem_alloc
1.04ms 733. 74.38KB
Пример кода
f2_5 <- function(dt){
x <- dt$case_id + runif(2 * nrow(dt), 0, 1)
m <- matrix(sort(x), ncol = 2, byrow = TRUE) - dt$case_id
set(dt, j = "ts_idx", value = m[, 1])
set(dt, j = "tf_idx", value = m[, 2])
}
Перфекционизм в действии. Еще в 4 раза ускорили.
median `itr/sec` mem_alloc
278.1us 2781. 57.55KB
Соберем все вместе.
Тестируем
bench::mark(
f1(df),
f2(dt),
f2_5(dt),
check = FALSE
)
median `itr/sec` mem_alloc
28.16ms 30.7 2.06MB
1.04ms 733. 74.38KB
278.1us 2781. 57.55KB
Первичный наивный вариант также удалось бесплатно улучшить по производительности в 90 раз, а потребление памяти сократить в 35 раз.
Примеры вполне себе реальные, каждый может поэкспериментировать на своей машине. Абсолютные показатели будут, естественно, отличаться, но тренд останется неизменным. Чтобы убедиться, что большое количество кода в мире пишется по самым наивным вариантам, достаточно пошерстить репозитории на гитхабе. И ведь не всегда значительная оптимизация — это громадный непонятный код и уйма времени, см. примеры выше. Оптимизированный код даже компактнее и прозрачнее, устраняются промежуточные пассы.
Поэтому, когда представители BigData запрашивают немалые вычислительные ресурсы и требуемое время для относительно небольшой задачи, всегда терзает смутное сомнение, что под капотом что-то не то.
Источник статьи: https://habr.com/ru/post/562906/
Многие могут предположить, что нюансы необходимо было знать 50 лет назад, а сейчас можно пользоваться готовыми пакетами и функциями и не погружаться в детали. Однако, это далеко не так. Равно как никто не отменял важность понимания представления методов хранения данных в памяти и их обработки в процессоре.
Далее разберем нюансы на примере функций сортировки. Сортировка и поиск используются максимально часто во всех манипуляциях с данными. Экономия нескольких миллисекунд на операции может приводить к суммарному сокращению часов расчета на значительных данных.
Является продолжением серии предыдущих публикаций.
Введение
Сформулируем упрощенную постановку задачи, на основе практических задач по работе с временными рядами. Исследуется набор данных, содержащих некоторое количество прецедентов, в рамках которых происходят определенные последовательности событий.
Допустим, что для алгоритмической отработки необходимо подготовить тестовый датасет, содержащий три колонки:
- case_id — уникальный идентификатор кейса/прецедента;
- record — журнальная запись события в кейсе;
- start — время регистрации.
Используемые библиотеки
library(tidyverse)
library(data.table)
library(rTRNG)
Наиболее интересной задачей является генерация временных меток. События должны идти последовательно во времени для каждого кейса в отдельности. Сначала подготовим простую "рыбу". В частном случае мы возьмем для демонстрации малое число кейсов. В продуктиве их может быть 10^5-10^n, что определяется задачами.
Пример кода
# определим число кейсов
nn <- 100
# создаем первичный набор кейсов
records <- c("first", "one and a half", "second", "third", "fourth",
"fifth", "sixth")
# готовим два варианта для экспериментов
df <- tibble(case_id = 1:nn, recs = list(records)) %>%
unnest(recs)
dt <- as.data.table(df)[, case_id := as.numeric(case_id)]
# указание ключа приводит к физической сортировке данных
setkey(dt, case_id)
head(df, 10)
# A tibble: 10 x 2
case_id recs
<int> <chr>
1 1 first
2 1 one and a half
3 1 second
4 1 third
5 1 fourth
6 1 fifth
7 1 sixth
8 2 first
9 2 one and a half
10 2 second
Теперь приступим к интересному блоку — генерации временных меток. Для простоты задачи сведем ее к распределению долей в интервале [0; 1] в рамках каждого кейса. Перевод в реальный unixtimestamp оставим за пределами, это неинтересно. Варианты с явными циклами также за пределами. Времена исполнения приведены на условном компьютере, главное, что выполняется все на одном.
Создание одной временнОй метки
Вариант 1. Прямолинейный
Этот вариант предлагается в большинстве случаев. А что, все просто и понятно.
Пример кода
f1 <- function(df) {
df %>%
group_by(case_id) %>%
mutate(t_idx = sort(runif(n(), 0, 1))) %>%
ungroup()
}
Получаем такие условные показатели. Наверное, неплохо. Но не забываем, что тут всего 100 кейсов.
median `itr/sec` mem_alloc
15.38ms 63.2 284.9KB
Подумаем, что можно улучшить?
Вариант 1+1/2. Прямолинейный + быстрый генератор чисел
Есть хорошая библиотека rTRNG. На больших объемах она дает существенное ускорение, в том числе, за счет параллельной генерации. Просто проверим:
Пример кода
f1_5 <- function(df) {
df %>%
group_by(case_id) %>%
mutate(t_idx = sort(runif_trng(n(), 0, 1))) %>%
ungroup()
}
median `itr/sec` mem_alloc
29.34ms 29.5 284.9KB
На малых объемах не получили никакого выигрыша. Это все? Конечно же нет. Мы знаем, что tidyverse медленнее data.table, попробуем применить его.
Вариант 2. Однопроходный, через индексы data.table
Здесь мы попробуем применить первую хитрость — отсортировать вектор времен по индексам, а потом его переприсвоить.
Пример кода
f2 <- function(dt) {
# здесь полагаемся на то, что мы заранее отсортировали уже по `case_id``
# формируем случайные числа и сортируем их по кейсам
vec <- dt[, t_idx := runif_trng(.N, 0, 1)][order(case_id, t_idx), t_idx]
# возвращаем сортированный
dt[, t_idx := vec]
}
Получается вполне неплохо, ускорение раз в 15-20 и памяти требуется почти в три раза меньше.
median `itr/sec` mem_alloc
1.69ms 554. 109KB
Останавливаемся? А почему да?
Вариант 3. Однопроходный, через композитный индекс
На самом деле, как только мы сваливаемся в цикл, явный, или через by, мы резко просаживаемся в производительности. Попробуем сделать все за один проход. Идея следующая — сделать композитный индекс, который позволил бы нам отсортировать все события одним махом. Используем трюк. Поскольку у нас внутри кейса все временные метки будут в диапазоне [0; 1], то мы можем разделить индекс на две части. Целочисленная часть будет содержать case_id, дробная часть — временнУю долю. Однократная сортировка одного такого индекса сохранит принадлежность строчек case_id, при этом мы одним махом отсортируем значения внутри каждого кейса
Пример кода
f3 <- function(dt) {
# делаем трюк, формируем композитный индекс из case_id, который является монотонным, и смещением по времени
# поскольку случайные числа генерятся в диапазоне [0, 1], мы их утаскиваем в дробную часть (за запятую)
# сначала просто генерируем случайные числа от 0 до 1 для каждой записи отдельно
# и масштабируем одним вектором
dt[, t_idx := sort(case_id + runif_trng(.N, 0, 1, parallelGrain = 10000L)) - case_id]
}
Запускаем и получаем выигрыш еще в 2 раза против предыдущего варианта, как по времени, так и по памяти.
median `itr/sec` mem_alloc
826.7us 1013. 54.3KB
Вариант 3+1/2. Однопроходный, через композитный индекс, используем set
Останавливаемся? Можно и остановиться, хотя поле для сжатия еще есть. Дело в том, что при таких малых временах исполнения накладные расходы на NSE становятся весьма ощутимыми. Если использовать прямые функции, то можно получить куда лучший результат.
Пример кода
f3_5 <- function(dt) {
set(dt, j = "t_idx",
value = sort(dt$case_id + runif(nrow(dt), 0, 1)) - dt$case_id)
}
Ускорение еще в 5 раз, памяти потребляем в 4 раза меньше
median `itr/sec` mem_alloc
161.5us 5519. 16.3KB
Промежуточное подведение итогов
Соберем все вместе.
Тестируем
bench::mark(
f1(df),
f1_5(df),
f2(dt),
f3(dt),
f3_5(dt),
check = FALSE
)
expression min median `itr/sec` mem_alloc
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt>
1 f1(df) 14.3ms 15.38ms 63.2 284.9KB
2 f1_5(df) 24.43ms 29.34ms 29.5 284.9KB
3 f2(dt) 1.55ms 1.69ms 554. 109KB
4 f3(dt) 722us 826.7us 1013. 54.3KB
5 f3_5(dt) 142.5us 161.5us 5519. 16.3KB
Путем изучения принципов работы алгоритмов и пристального взгляда на задачу первичный наивный вариант удалось бесплатно улучшить по производительности в 90 раз, а потребление памяти сократить в 18 раз. Это показатели, которые слабо достижимы горизонтальным масштабированием серверов.
Создание временнОй метки начала записи и окончания
Усложним немного задачу. Теперь для каждой записи надо сформировать время начала и время окончания ("прием у врача"). При этом все записи должны соблюдать исходный порядок следования во времени и время окончания для каждой записи должно быть не раньше времени начала.
Количество рассматриваемых сценариев сократим, оставим только наиболее интересные. И всякие варианты с явными циклами тоже даже не упоминаем.
Вариант 1. Прямолинейный
Первый наиболее разумный и очевидный вариант — создать метки начала, как в предыдущей задаче и потом для каждой метки поставить случайное окончание, так, чтобы соблюдались правила. Итерируем построчно, есть специфика, что долевое начало следующего кейса будет, как правило, меньше, чем начало последней операции предыдущего. Но это тоже решаемо. Код может выглядеть примерно так:
Пример кода
# Cоздание ЧЕТЫРЕХ колонок -- case_id, record, start, finish
# Все как в предыдущем, только для каждого записи finish > start
# и для двух последовательных записей 1, 2 в одном кейсе start_2 > finish_1
dt[, t_idx := NULL] # очистим хвосты предыдущего упражнения
f1 <- function(df) {
df %>%
group_by(case_id) %>%
mutate(ts_idx = sort(runif(n(), 0, 1))) %>%
ungroup() %>%
# еще раз пройдемся генератором, используя время начала следующей записи как границу
# чтобы избежать NaN при переходе между кейсами (в случае max < min),
# принудительно выставим порог 1 в таких переходах, NA в последней строке тоже заменим на 1
mutate(tf_idx = {lead(ts_idx, default = 1) %>% if_else(. > ts_idx, ., 1)}) %>%
mutate(tf_idx = map2_dbl(ts_idx, tf_idx, ~runif(1, .x, .y)))
}
В целом меньше секунды, но, очевидно, что это ОЧЕНЬ далеко от оптимальности.
median `itr/sec` mem_alloc
28.16ms 30.7 2.06MB
Вариант 2. Однопроходный, через композитный индекс и матрицы
Идея следующая. Нам нужно получить 2 колонки, в которых значения будут отсортированы строчка за строчкой и значение первой колонки не превышает значение второй колонки для каждой строки. Но это же матрица! Матрица — это единый кусок в памяти, который сопровождается атрибутами сегментирования. Работа с ними куда быстрее классических датафреймов.
Пример кода
f2 <- function(dt){
dt[, c("ts_idx", "tf_idx") := {
# используем принцип vector recycling
x <- case_id + runif(2 * .N, 0, 1);
m <- matrix(sort(x), ncol = 2, byrow = TRUE) - case_id;
list(m[, 1], m[, 2])
}]
}
В легкую сократили время и память почти в 30 раз! Да и код стал существенно проще и прямолинейнее.
median `itr/sec` mem_alloc
1.04ms 733. 74.38KB
Вариант 2+1/2. Однопроходный, через композитный индекс, матрицы и set
Пример кода
f2_5 <- function(dt){
x <- dt$case_id + runif(2 * nrow(dt), 0, 1)
m <- matrix(sort(x), ncol = 2, byrow = TRUE) - dt$case_id
set(dt, j = "ts_idx", value = m[, 1])
set(dt, j = "tf_idx", value = m[, 2])
}
Перфекционизм в действии. Еще в 4 раза ускорили.
median `itr/sec` mem_alloc
278.1us 2781. 57.55KB
Промежуточное подведение итогов
Соберем все вместе.
Тестируем
bench::mark(
f1(df),
f2(dt),
f2_5(dt),
check = FALSE
)
median `itr/sec` mem_alloc
28.16ms 30.7 2.06MB
1.04ms 733. 74.38KB
278.1us 2781. 57.55KB
Первичный наивный вариант также удалось бесплатно улучшить по производительности в 90 раз, а потребление памяти сократить в 35 раз.
Заключение
Примеры вполне себе реальные, каждый может поэкспериментировать на своей машине. Абсолютные показатели будут, естественно, отличаться, но тренд останется неизменным. Чтобы убедиться, что большое количество кода в мире пишется по самым наивным вариантам, достаточно пошерстить репозитории на гитхабе. И ведь не всегда значительная оптимизация — это громадный непонятный код и уйма времени, см. примеры выше. Оптимизированный код даже компактнее и прозрачнее, устраняются промежуточные пассы.
Поэтому, когда представители BigData запрашивают немалые вычислительные ресурсы и требуемое время для относительно небольшой задачи, всегда терзает смутное сомнение, что под капотом что-то не то.
Источник статьи: https://habr.com/ru/post/562906/