Наскільки R швидкий для продуктива?
Є такий популярний клас задач, в яких потрібно проводити досить глибокий аналіз всього обсягу ланцюжків робіт, зареєстрованих якої-небудь інформаційної системи (ІС). В якості ІС може бути документообіг, сервіс деск, багтрекер, електронний журнал, складський облік і пр. Нюанси виявляються в моделях даних, API, обсяги даних і інших аспектах, але принципи вирішення таких завдань приблизно однакові. І граблі, на які можна наступити, теж багато в чому схожі.
Для вирішення такого класу задач R підходить як не можна краще. Але, щоб не розводити розчаровано руками, що R може і хороший, але дуже повільний, важливо звертати увагу на продуктивність вибраних методів обробки даних.
Зазвичай, поверхневий підхід «в лоб» не є найефективнішим. 99% завдань, пов’язаних з аналізом і обробкою даних починаються з їх імпорту. У цьому короткому нарисі розглянемо проблеми, що виникають на базовому етапі імпорту даних, на прикладі типової задачі «глибокого» аналізу даних інсталяції Jira.
Постановка задачі
Дано:
- jira впроваджена і використовується в процесі розробки ПЗ як система управління завданнями і багтрекер.
- Прямого доступу до БД jira немає, взаємодія здійснюється через REST API (гальванічна розв’язка).
- Забираемые json файли мають досить складну деревоподібну структуру з вкладеними кортежами, необхідні для вивантаження всієї історії дій. Для розрахунку ж метрик потрібна відносно невелика кількість параметрів, розкиданих по різних рівнях ієрархії.
Приклад штатного jira json на малюнку.
Вимагається:
- На підставі даних jira необхідно знайти вузькі місця і точки можливого зростання ефективності процесів розробки і підвищення якості одержуваного продукту на основі аналізу всіх зареєстрованих дій.
Рішення
Теоретично в R є кілька різних пакетів по завантаженню json і перетворення їх в data.frame
. Найбільш зручним виглядає пакет jsonlite
. Однак, пряме перетворення ієрархії json в data.frame
важко в силу багаторівневого вкладення і сильної параметризированности структури записів. Выцепление конкретних параметрів, пов’язаних, наприклад, з історією дій, може вимагати різних дод. перевірок і циклів. Тобто завдання можна вирішити, але для json файлу розміром в 32 завдання (включає всі артефакти і всю історію завдань) такий нелінійний розбір засобами jsonlite і tidyverse займає ~10 секунд на ноутбуці середньої продуктивності.
Самі по собі 10 секунд — це небагато. Але рівно до моменту, поки цих файлів не стає занадто багато. Оцінка на семпли розбору і завантаження подібним прямим методом ~4000 файлів (~4 Гб) дала 8-9 годин роботи.
Таку велику кількість файлів з’явилося неспроста. По-перше, jira має тимчасові обмеження на REST сесію, витягнути все балком неможливо. По-друге, будучи вбудованим в продуктивний контур, очікується щоденна вивантаження даних з оновленими завданнями. По-третє, і це буде згадано далі, завдання дуже хороша для лінійного масштабування і думати про паралелізації треба з самого першого кроку.
Навіть 10-15 ітерацій на етапі аналізу даних, виявлення необхідного мінімального набору параметрів, виявлення виняткових або помилкових ситуацій і вироблення алгоритмів постпроцесингу дають витрати в розмірі 2-3 тижні (тільки рахунковий час).
Природно, що подібна «продуктивність» не підходить для операційної аналітики, вбудованої в продуктивний контур, і дуже неффективно на етапі первинного аналізу даних і розробки прототипу.
Пропускаючи всі проміжні деталі, відразу переходжу до відповідальності. Згадуємо Дональда Кнута, засучиваем рукави і починаємо займатися микробенчмаркингом всіх ключових операцій безжально зрізуючи все, що тільки можна.
Результуюче рішення зводиться до наступних 10 рядками (це сутевой скелет, без подальшого нефункціонального обважування):
library(tidyverse)
library(jsonlite)
library(readtext)
fnames <- fs::dir_ls(here::here("input_data"), glob = "*.txt")
ff <- function(fname){
json_vec <- readtext(fname, text_field = "texts", encoding = "UTF-8") %>%
.$text %>%
jqr::jq('[. | {issues: .issues}[] | .[]',
'{id: .id key: .key, created: .fields.created,
type: .fields.issuetype.name, summary: .fields.summary,
descr: .fields.description}]')
jsonlite::fromJSON(json_vec, flatten = TRUE)
}
tictoc::tic("Loading with jqr-jsonlite single-threaded technique")
issues_df <- fnames %>%
purrr::map(ff) %>%
data.table::rbindlist(use.names = FALSE)
tictoc::toc()
system.time({fst::write_fst(issues_df, here::here("data", "issues.fst"))})
Що тут цікавого?
- Для прискорення процесу завантаження добре використовувати спеціалізовані профільовані пакети, такі як
readtext
. - Застосування потокового парсера
jq
дозволяє перевести всі выцепление потрібних атрибутів на функціональний мову, опустити його на CPP рівень і мінімізувати ручні маніпуляції над вкладеними списками або спискамиdata.frame
. - З’явився дуже перспективний пакет
bench
для микробенчамарков. Він дозволяє вивчати не тільки час виконання операцій, але і маніпуляції з пам’яттю. Не секрет, що на копіювання даних в пам’яті можна втрачати дуже багато. - Для великих обсягів даних і простий обробки часто доводиться у фінальному вирішенні відмовлятися від
tidyverse
і переводити трудомісткі частини наdata.table
, зокрема тут йде злиття таблиць засобами самеdata.table
. А також всі перетворення на етапі постпроцесингу (які включені в цикл допомогою функціїff
також зроблені засобамиdata.table
з підходом зміни даних за посиланням, або пакетами, побудованими з застосуваннямRcpp
, наприклад, пакетanytime
для роботи з датами і часом. - Для скидання даних у файл і подальшого читання дуже хороший пакет
fst
. Зокрема, лише частки секунди йдуть на збереження всієї аналітики jira історії за 4 роки, а дані зберігаються саме як типи даних R, що добре для подальшого їх перевикористання.
В ході рішення був розглянутий підхід із застосуванням пакета rjson
. Варіант jsonlite::fromJSON
приблизно в 2 рази повільніше, ніж rjson = rjson::fromJSON(json_vec)
, але довелося залишити саме його, бо в даних бувають NULL значення, а на етапі перетворення NULL
в NA
в списках, що видаються rjson
ми втрачаємо перевагу, а код збільшується.
Висновок
- Подібний рефакторинг привів до зміни часу процесингу всіх json файлів в однопоточному режимі на цьому ж ноутбуці за 8-9 годин до 10 хвилин.
- Додавання паралелізації задачі засобами
foreach
практично не утяжелило код (+ 5 рядків), але знизило час виконання до 5 хвилин. - Переклад рішення на слабенький linux сервер (всього 4 ядра), але працює на SSD в багатопотоковому режимі звело час виконання до 40 секунд.
- Публікація на продуктивний контур (20 ядер, 3 ГГц, SSD) дало зниження часу виконання до 6-8 секунд, що є більш ніж прийнятним для задач операційної аналітики.
Отже, залишаючись в рамках платформи R, простим рефакторінгом коду вдалося домогтися зменшення часу виконання з ~9 годин до ~9 секунд.
Рішення на R можуть бути цілком швидкими. Якщо у вас щось не виходить, спробуйте поглянути на це під іншим кутом і з застосуванням свіжих методик.