В комментариях к видео Идеальный скрипт на баш 2 мне сказали, что в bash в конструкции if надо использовать "<" вместо "-lt", который применял я. Давайте разберёмся, как вообще измерять время работы программы и что на это влияет. Вас ждёт фарш из кучи команд: htop, iotop, lscpu, time, xargs, yes, seq, sync, timeout и хтонический ужас однострочников на bash. Материал в видео, кому удобнее — ниже его текстовая версия.
Кроме ютуба, для удобства есть дзен / VK / rutube.
Сегодня разберём, как корректно измерять время выполнения программы и какие причины влияют производительность. Посмотрим, как можно загрузить систему и наглядно посмотреть на её состояние.
Сравним скорость -lt и < внутри if мы в следующем видео. Начать придётся издалека, через тернии к звёздам.
Что вообще важно для любого эксперимента? Описать методику эксперимента. Для измерения скорости обратите внимание на следующие нюансы:
0. На каком оборудовании мы работаем
1. Измерять секунды и десятки секунд, а не наносекунды
2. Фиксировать условия по загрузка ядер и видеокарты, оперативной памяти, вводу/выводу
3. И сколько измерений проводить и как усреднять, средняя или медиана, что делать с выбросами и подобное
Не будем вдаваться детали инструментов конкретных языков программирования, где можно измерять время выполнения конкретной функции. Не будем также говорить об очень малых замерах, где нужно учитывать непосредственно тики процессора. Будем измерять процессы, длящиеся секунды штатными средствами операционной системы Linux.
В начале про оборудование. Посмотрим на доступные ядра нашей системы с помощью команды lscpu. Видим 4 интелловых ядра i7-3770 с гипертредингом в 2 потока каждое, то есть 8 виртуальных ядер. Запомним - это наше оборудование
lscpu
...
CPU(s): 8
Потоков на ядро: 2
Ядер на сокет: 4
Имя модели: Intel(R) Core(TM) i7-3770 CPU @ 3.40GHz
...
Для длительных временных промежутков достаточно встроенной в bash команды time:
type time
time — это ключевое слово командного процессора
Справка нас обманывает, потому что показывает информацию о бинарнике /usr/bin/time
А это не то, что нужно. Это справка по такой команде:
/usr/bin/time sleep 1
0.00user 0.00system 0:01.00elapsed 0%CPU (0avgtext+0avgdata 2260maxresident)k
72inputs+0outputs (1major+95minor)pagefaults 0swaps
Мне такой вывод непривычен, поэтому вернёмся к встроенному time:
time sleep 1
real 0m1,002s — общее время работы программы
user 0m0,002s — время в пользовательском режиме
sys 0m0,000s — время в режиме ядра или системных вызовов
До эры искуственного интеллекта в виде ChatGPT был популярен stackoverflow, детали смотрите там. Важно обратить внимание, что real — это про общее время работы, а user и sys — время процессорное. Для них 10 будет означать, что программа работала 10 ядросекунд — при этом неважно, 10 секунд на 1 ядро или 1 секунда на 10 ядрах
Для примера попробуем посчитать уникальные строки в большом файлике на 130 мегабайт и 14 млн уникальных строк.
time sort -u rockyou.txt | wc -l
14341564
real 0m34,771s
user 1m7,906s
sys 0m0,931s
Вот тут наглядно видно, что real — это совсем не сумма user и sys. Кстати, вам интересно, как можно изящно ускорить подсчёт уникальных строк за счёт магии баша? Их есть у меня! Лайкните этот пост, и я сниму отдельный ролик про всякие хаки и оптимизации на этом поприще.
Про встроенную команду time мы можем найти в справке по самому bash
Ищем там с помощью ввода /time (слеш, потом time), дальше сильно промотав вниз с помощью кнопки n (сокращение от next, следующий поиск). Тут и описание вывода, и переменная окружения TIMEFORMAT, с помощью которой можно настроить вывод time.
Больше о переменных окружения в нашем бесплатном курсе на степике под названием Командная строка для разработчиков – cli-for-dev.
Проще, конечно, загуглить нужное, кому нужна эта справка. Для вывода только real нам понадобится такая конструкция
TIMEFORMAT="%R"; time sleep 1
Запомнили, это пригодится позднее. Дальше о фиксированных условиях.
С этим пунктом мы надолго. Теперь посмотрим на загрузку системы и как она влияет на время работы команды. Мне нравится команда htop за наглядность. Тут показывается загрузка каждого ядра в режиме реального времени и многое другое. Строку load average мы с вами разбирали в видео про атаку forkbomb в docker-контейнере. Посмотрите, если пропустили.
Загрузим вычислитель. Нам нужно взять "тяжёлую" для процессора команду и запустить её, загрузив все логические ядра системы. Согласно философии Unix, программа должна решать одну задачу и решать её хорошо. Команда xargs умеет параллелить. Она берёт входную строку и преобразует её в одну или несколько команд. Для наглядности покажу, как xargs работает в связке с echo:
echo 123 | xargs echo my arg is
Если аргументов несколько, по умолчанию они все пойдут указанной команде:
echo 1 2 3 | xargs echo my arg is
С помощью флага -n можно настроить, сколько аргументов пойдёт в одну команду. Если указать один, то наши три числа превратятся в три разные команды:
echo 1 2 3 | xargs -n1 echo my arg is
Если указать два, то первые два аргумента пойдут в первую команду, оставшийся третий аргумент пойдёт во вторую:
echo 1 2 3 | xargs -n2 echo my arg is
Вернёмся к одному аргументу на команду. И теперь магия - укажем, что надо запускаться параллельно на всех ядрах с помощью P0
echo 1 2 3 | xargs -P0 -n1 echo my arg is
Блеск. Дело за малым - нам надо загрузить всю систему. В моем случае, как мы выяснили в lscpu, надо 8 потоков. Воспользуемся командой seq, сокращение от sequence
seq 8
seq 8 | xargs -P0 -n1 echo hello process
Чудо. Мы умеем параллельно запускать 8 команд. Теперь надо сделать так, чтобы эти команды сурово грузили процессор. Можно использовать сторонние тулзы нагрузочного тестирования типа stress, но зачем?
Пойдём рабоче-крестьянским путём
Есть команда yes, которая умеет адски спамить. По умолчанию она спамит буквой y, то есть всегда говорит да, прямо как Джим Керри в комедии 2008 года:
Может спамить любой строкой:
Прикол в том, что этот спам сильно грузить процессор, что нам и нужно. Направляем вывод команды yes в чёрную дыру dev null:
После чего смотрим в htop на загрузку. И получаем отличную жужалку, которая нагружает ядро. Но только одно. Постойте, мы же умеем параллелить!
seq 8 | xargs -P0 -n1 yes > /dev/null
Всё хорошо, но эта команда работает, пока мы её не прервём. А нам бы добавить немного удобства. Пусть система загрузится на 10 секунд. Линукс и так умеет. Команда timeout прервёт запущенную команду, если она сама не завершиться за указанное время, в моём примере 1 секунду:
И мы ещё time можем сюда навесить. Измерим время команды yes, которую прервёт по таймауту:
Я вас ещё не совсем замучил? Теперь объединим всё это безобразие. На 10 секунд загрузим 8 ядер системы собранной нами на коленке жужжалкой:
seq 8 | xargs -P0 -n1 timeout 10 yes > /dev/null
В рамках нашего примера есть два сходных варианта:
seq 8 | xargs -P0 -n1 timeout 10 yes > /dev/null # Грузим sys
seq 8 | xargs -P0 -n1 timeout 10 md5sum /dev/zero # Грузим user
Теперь у нас готова жужжалка для загрузки ядра процессора. С загрузкой других компонент системы разобраться проще. Для загрузки оперативной памяти достаточно создать огромную переменную:
a=$(seq 10)
echo $a
a=$(seq 100 000 000)
Но совершенно неудобно оценивать размер. Поэтому воспользуемся питоном. Незатейливо создадим большую строку, благо звёздочка удобно переопределена для строк. Один английский символ занимает один байт плюс накладные расходы на переменную, берём их "кило", то есть 1024 и получим килобайт.
В IDLE можно не писать print, результат работы команды выводится на экран:
Кстати, в питоне накладные расходы на переменную довольно велики. В случае строки это 49 байт:
import sys
var = ""
sys.getsizeof(var) # 49
var = "a"
sys.getsizeof(var) # 50
var = "a" * 100
sys.getsizeof(var) # 149
Возведём 1024 в степень. Вторая степень даст мегабайт, третья - гигабайт. То есть такая переменная займёт в памяти около гигабайта, плюс 49 байт на служебную информацию.
У нас свободно около 5 гигабайт, займём их все. Пока не удалим эту переменную или не завершим интерпретатор питона, память будет загружена.
Для просмотра загруженности подсистемы ввода-вывода есть команда iotop, которой требуются права суперпользователя. Нам нужны только первые две строки
Для загрузки ввода-вывода возьмём псевдогенератор случайных чисел и будем записывать его в файл:
cat /dev/urandom > /tmp/1
Зачастую проблема не в самом вводе-выводе, а в его буферизации. Так называется отложенная запись на диск. Операционная система для более эффективной работы с оборудованием пишет не сразу. Например, после записи файла на флешку на самом деле он там окажется не сразу, а через некоторое время. Для этого существует (или существовало? кто видел флешку в 2024 году?) безопасное извлечение флешки – как раз, чтобы операционка корректно дописала отложенный буфер.
В линуксе есть команда sync, которая завершиться, когда весь буфер запишется на диск.
Пробовать измерять время будем на примере цикла в баше:
i=0; while [[ $i -lt 10 ]]; do i=$(( $i+1 )); echo $i ; done; echo $i
i=0; while [[ $i -lt 1000000 ]]; do i=$(( $i+1 )); done; echo $i
Добавим time. Если просто добавить time, то мы измерим время только присваивания:
time i=0; while [[ $i -lt 1000000 ]]; do i=$(( $i+1 )) ; done; echo $i
Чтобы измерить время всей конструкции, оформим цикл в виде отдельного процесса с помощью доллара и скобок:
time $( i=0; while [[ $i -lt 1000000 ]]; do i=$(( $i+1 )) ; done; echo $i )
Ещё ошибка: результат подпроцесса - число, а команда time пытается это число выполнить как команду. Добавим echo:
time echo $( i=0; while [[ $i -lt 1000000 ]]; do i=$(( $i+1 )) ; done; echo $i )
Теперь посмотрим, как на этот цикл влияет загрузку системы. Запустим монитор ресурсов htop и загрузим ядро:
seq 8 | xargs -P0 -n1 timeout 10 yes > /dev/null
Попробуем запустить наш цикл. Потом загрузим оперативную память:
python3.10
var = "a"*5*1024**3
И снова цикл. Осталось загрузить ввод-вывод:
sudo iotop
cat /dev/urandom > /tmp/1
Одного измерения недостаточно. Собственно, примеры загрузки системы выше были призваны проиллюстрировать влияние внешних факторов на запуск вашего кода. Для минимизации этого влияния следует измерить несколько раз. Можно ручками внести результаты в таблицу. Но, во-первых, мы люди ленивые. А во-вторых, ручная обработка всегда чревата ошибками. Соберём всё в табличку.
А с этим есть проблема. Для вывода time в файл придётся немного извратиться. Дело в том, что перенаправление будет работать для команды выполняемой команды, то есть для всего справа от time. На помощь придёт логическое объединение в виде group command с помощью фигурных скобочек:
Ищем такую конструкцию /\{
Слеш для поиска, потом экранирование обратным слешом фигурной скобки. Как видно из справки, нужно добавить фигурные скобки и точку с запятой в конце, и только потом перенаправить. При этом time пишет в стандартный поток ошибок, то есть нужно перенаправлять второй дескриптор. Получается вот так:
TIMEFORMAT="%R"; { time $( i=0; while [[ $i -lt 1000000 ]]; do i=$(( $i+1 )) ; done; ) ; } 2>> res.csv;
tail -f res.csv # для проверки, что пишет
libreoffice res.csv # для обработки итоговой таблицы после завершения
Если запустить libreoffice с английским языком, то запятая будет считаться разделителем разрядов и удалится, мы получим неверное время (4567 вместо 4,567).
Закроем файл и откроем снова. Переключим язык на русский, чтобы запятая стала десятичным разделителем. Впишем формулы СРЗНАЧ и СТОТКЛ.
Если запускать скрипт 10 раз лень, можно накинуть ещё цикл (больше циклов богу циклов):
for i in $( seq 2 ); do TIMEFORMAT="%R"; { time $( i=0; while [[ $i -lt 1000000 ]]; do i=$(( $i+1 )) ; done; ) ; } 2>> res.csv; done
Резюмируем. Наша методика экспериментального исследования времени работы программы выглядит так:
берём 8-ядерный i7-3770
проводим 10 измерений командой time
запускаем много циклов "-lt" vs "<"
рассчитываем среднее арифметическое и среднеквадратичное отклонение
В следующем видео заодно интереса ради посмотрим на другие языки.
Теперь вы знаете, как можно загрузить процессор, оперативную память или подсистему ввода-вывода, как посмотреть на эту загрузку и как она может влиять на программу. Такие дела.
В телеграм-канале DevFM пишу о полезном для разработчика: инструментах, например, postgres_dba для анализа узких мест базы данных или fcron, интересных хаках вроде запуска LLM прямо в шрифте, или о Google design docs. А ещё у нас есть бесплатный курс cli-for-dev по Linux на степике, немного подкастов и видео.