Мощь можества ядер для укрощения кодека AV1
Пролог
Периодически, я интересуюсь видеокодеками и тем, насколько они становятся эффективнее по сравнению со своими предшественниками. В свое время, когда после H264 вышел HEVC, мне было безумно интересно его пощупать, но мое железо того времени оставляло желать лучшего.
Сейчас же железо подтянулось, но и HEVC давно устарел, ему на смену жаждет придти открытый AV1, обещающий нам до 50% экономии по сравнению с 1080p H264, но если скорость качественного кодирования в HEVC кажется медленноватой (по сравнения с H264), то AV1 со своим ~0.2 fps деморализует полностью. Когда что-то кодируется настолько медленно, то это значит, что даже простой 10 минутный ролик, будет обрабатываться около суток. Т.е. чтобы просто посмотреть подходят ли параметры кодирования или нужно добавить немного битрейта, придется ждать не просто часами, а днями…
И вот, как-то раз, любуясь красивым закатом (кодека H264), я подумал: «А что, если натравить на AV1 все железо которое у меня есть одновременно?»
Идея
Я пробовал кодировать AV1 с использование тайлов и многоядерности, но прирост производительности показался мне не таким уж эффективным на каждое добавленное ядро процессора, давая около полтора FPS при самых быстрых настройках и 0.2 при медленных, поэтому в голову пришла кардинально другая идея.
Посмотрев, что у нас есть на сегодня актуального по AV1, я составил список:
- Встроенный в ffmpeg кодировщик libaom-av1
- Проект rav1e
- Проект SVT-AV1
Из всего вышеперечисленного я выбрал rav1e. Он показал очень неплохую однопоточную производительность и идеально ложился в систему, которую я придумал:
- Кодировщик разрежет исходное видео на кусочки по n секунд
- На каждом моем компьютере будет web-сервер со специальным скриптом
- Мы кодируем в один поток, а значит сервер может одновременно кодировать столько кусочков, сколько у него процессорных ядер
- Кодировщик будет отправлять кусочки на серверы, и скачивать обратно закодированные результаты
- Когда все кусочки будут готовы, кодировщик склеит их в один и наложит звук из исходного файла
Реализация
Сразу скажу, что реализация сделана под Windows. В теории ничего не мешает сделать тоже самое и под другие ОС, но я делал под то, что стояло у меня.
Итак нам нужно:
- Web-сервер с PHP
- ffmpeg
- rav1e
1. Для начала нам понадобится Web-сервер, я не буду расписывать, что и как я настраивал, для этого есть очень много инструкций на любой вкус и цвет. Я использовал Apache + PHP. Для PHP важно сделать настройку позволяющую ему получать большие файлы (по дефолту в настройках 2Мб и это мало, наши кусочки могут быть больше). Из плагинов ничего особенного, CURL, JSON.
Также упомяну про безопасность, которой нет. Все, что я делал — я делал внутри локальной сети, поэтому никаких проверок и авторизаций не сделано, а возможностей для нанесения вреда злоумышленниками — полно. Поэтому, если это будет тестироваться не в защищенных сетях, вопросами безопасности нужно озаботится самостоятельно.
2. FFmpeg — готовые бинарники я качал с Zeranoe builds
3. rav1e — также можно скачать бинарник из релизов проекта rav1e
Принцип работы такой:
- Получаем кусочек, сохраняем его в папку
- Генерим CMD файл с командой для кодирования и удаление самого CMD файла в конце
- Запускаем CMD файл
В результате если:
- Есть исходный файл, есть файл результата и есть CMD файл — кодирование еще идет
- Есть исходный файл, есть файл результата и CMD файла нет — кодирование завершено
Соглашусь, что тут что-то может пойти не так, но только в нештатных режимах, вроде перезагрузки ПК с недоделанной работой, но… черт с ним, сойдет, я договорился со своей совестью.
Скрипт сообщает сколько у него всего потоков и сколько использовано, чтобы кодировщик решал, присылать ему еще кусочки или нет. Также скрипт сообщает о том, какие кусочки сейчас в работе и какие готовы, чтобы кодировщик мог скачать готовые и удалить их с сервера.
encoding.php:
count($active),
"total" => get_total_cpu_cores(),
"inProgress" => [],
"done" => []
];
foreach ($all as $key)
{
$pi = pathinfo($key);
$commandFile = $pi["filename"].".cmd";
$sourceFile = $pi["filename"];
if (file_exists($filesDir.'\\'.$sourceFile))
{
if (file_exists($filesDir.'\\'.$commandFile))
{
$info["inProgress"][] = $sourceFile;
}
else
{
$info["done"][] = $sourceFile;
}
}
}
if (isset($_GET["action"]))
{
if ($_GET["action"] == "upload" && isset($_FILES['encfile']) && isset($_POST["params"]))
{
$params = json_decode(hex2bin($_POST["params"]), true);
$fileName = $_FILES['encfile']['name'];
$fileToProcess = $filesDir."\\".$fileName;
move_uploaded_file($_FILES['encfile']['tmp_name'], $fileToProcess);
$commandFile = $fileToProcess.".cmd";
$resultFile = $resultDir."\\".$fileName.$params["outputExt"];
$command = $params["commandLine"];
$command = str_replace("%SRC%", $fileToProcess, $command);
$command = str_replace("%DST%", $resultFile, $command);
$command .= PHP_EOL.'DEL /Q "'.$commandFile.'"';
file_put_contents($commandFile, $command);
pclose(popen('start "" /B "'.$commandFile.'"', "r"));
}
if ($_GET["action"] == "info")
{
header("Content-Type: application/json");
echo json_encode($info);
die();
}
if ($_GET["action"] == "get")
{
if (isset($_POST["name"]) && isset($_POST["params"]))
{
$params = json_decode(hex2bin($_POST["params"]), true);
$fileName = antiHack($_POST["name"]);
$fileToGet = $filesDir."\\".$fileName;
$commandFile = $fileToGet.".cmd";
$resultFile = $resultDir."\\".$fileName.$params["outputExt"];
if (file_exists($fileToGet) && !file_exists($commandFile) && file_exists($resultFile))
{
$fp = fopen($resultFile, 'rb');
header("Content-Type: application/octet-stream");
header("Content-Length: ".filesize($resultFile));
fpassthru($fp);
exit;
}
}
}
if ($_GET["action"] == "remove")
{
if (isset($_POST["name"]) && isset($_POST["params"]))
{
$params = json_decode(hex2bin($_POST["params"]), true);
$fileName = antiHack($_POST["name"]);
$fileToGet = $filesDir."\\".$fileName;
$commandFile = $fileToGet.".cmd";
$resultFile = $resultDir."\\".$fileName.$params["outputExt"];
if (file_exists($fileToGet) && !file_exists($commandFile))
{
if (file_exists($resultFile))
{
unlink($resultFile);
}
unlink($fileToGet);
header("Content-Type: application/json");
echo json_encode([ "result" => true ]);
die();
}
}
header("Content-Type: application/json");
echo json_encode([ "result" => false ]);
die();
}
}
echo "URL Correct";
?>
- c:\Apps\OneDrive\commands\bin\ffmpeg\ffmpeg.exe — из Zeranoe builds
- c:\Apps\OneDrive\commands\bin\ffmpeg\rav1e.exe — из проекта rav1e
Тут прописываются все ваши серверы:
$servers = [
"LOCAL" => "http://127.0.0.1:8000/remote/encoding.php",
"SERVER2" => "http://192.168.100.25:8000/remote/encoding.php",
];
encode.php:
'"c:\Apps\OneDrive\commands\bin\ffmpeg\ffmpeg" -i "%SRC%" -an -pix_fmt yuv420p -f yuv4mpegpipe - | "c:\Apps\OneDrive\commands\bin\ffmpeg\rav1e" - -s 5 --quantizer 130 -y --output "%DST%"',
"outputExt" => ".ivf"
];
$paramsData = bin2hex(json_encode($params));
$servers = [
"LOCAL" => "http://127.0.0.1:8000/remote/encoding.php",
"SERVER2" => "http://192.168.100.25:8000/remote/encoding.php",
];
if (isset($argc))
{
if ($argc > 1)
{
$fileToEncode = $argv[1];
$timeBegin = time();
$pi = pathinfo($fileToEncode);
$filePartName = $pi["dirname"]."\\".$pi["filename"]."_part%04d.mkv";
$fileList = $pi["dirname"]."\\".$pi["filename"]."_list.txt";
$joinedFileName = $pi["dirname"]."\\".$pi["filename"]."_joined.mkv";
$audioFileName = $pi["dirname"]."\\".$pi["filename"]."_audio.opus";
$finalFileName = $pi["dirname"]."\\".$pi["filename"]."_AV1.mkv";
exec($ffmpeg.' -i "'.$fileToEncode.'" -c copy -an -segment_time 00:00:10 -reset_timestamps 1 -f segment -y "'.$filePartName.'"');
exec($ffmpeg.' -i "'.$fileToEncode.'" -vn -acodec libopus -ab 128k -y "'.$audioFileName.'"');
$files = glob($pi["dirname"]."\\".$pi["filename"]."_part*.mkv");
$sourceParts = $files;
$resultParts = [];
$resultFiles = [];
$inProgress = [];
while (count($files) || count($inProgress))
{
foreach ($servers as $server => $url)
{
if( $curl = curl_init() )
{
curl_setopt($curl, CURLOPT_URL, $url."?action=info");
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
$out = curl_exec($curl);
curl_close($curl);
$info = json_decode($out, true);
//var_dump($info);
if (count($files))
{
if (intval($info["active"]) < intval($info["total"]))
{
$fileName = $files[0];
$key = pathinfo($fileName)["basename"];
$inProgress[] = $key;
//echo "Server: ".$url."\r\n";
echo "Sending part ".$key."[TO ".$server."]...";
if (!in_array($key, $info["done"]) && !in_array($key, $info["inProgress"]))
{
$cFile = curl_file_create($fileName);
$post = ['encfile'=> $cFile, 'params' => $paramsData];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url."?action=upload");
curl_setopt($ch, CURLOPT_POST,1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close ($ch);
}
echo " DONE\r\n";
echo " Total: ".count($sourceParts).", In Progress: ".count($inProgress).", Left: ".count($files)."\r\n";
$files = array_slice($files, 1);
}
}
if (count($info["done"]))
{
foreach ($info["done"] as $file)
{
if (($key = array_search($file, $inProgress)) !== false)
{
set_time_limit(0);
echo "Receiving part ".$file."... [FROM ".$server."]...";
$resultFile = $pi["dirname"]."\\".$file.".result".$params["outputExt"];
$fp = fopen($resultFile, 'w+');
$post = ['name' => $file, 'params' => $paramsData];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url."?action=get");
curl_setopt($ch, CURLOPT_POST,1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
//curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);
//fclose($fp);
$resultFiles[] = "file ".$resultFile;
$resultParts[] = $resultFile;
$post = ['name' => $file, 'params' => $paramsData];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url."?action=remove");
curl_setopt($ch, CURLOPT_POST,1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);
fclose($fp);
unset($inProgress[$key]);
echo " DONE\r\n";
echo " Total: ".count($sourceParts).", In Progress: ".count($inProgress).", Left: ".count($files)."\r\n";
}
}
}
}
}
usleep(300000);
}
asort($resultFiles);
file_put_contents($fileList, str_replace("\\", "/", implode("\r\n", $resultFiles)));
exec($ffmpeg.' -safe 0 -f concat -i "'.$fileList.'" -c copy -y "'.$joinedFileName.'"');
exec($ffmpeg.' -i "'.$joinedFileName.'" -i "'.$audioFileName.'" -c copy -y "'.$finalFileName.'"');
unlink($fileList);
unlink($audioFileName);
unlink($joinedFileName);
foreach ($sourceParts as $part)
{
unlink($part);
}
foreach ($resultParts as $part)
{
unlink($part);
}
echo "Total Time: ".(time() - $timeBegin)."s\r\n";
}
}
?>
Файл для запуска скрипта кодирования, лежит рядом со скриптом. Путь к PHP настраиваете сами.
encoding.cmd:
@ECHO OFF
cd /d %~dp0
SET /p FILENAME=Drag'n'Drop file here and Press Enter:
..\php7\php.exe -c ..\php7\php_standalone.ini encode.php "%FILENAME%"
PAUSE
Поехали?
Для теста я использовал известный мультик про кролика Big Bucks Bunny, длиной 10 минут и размером 150Мб.
Железо
- AMD Ryzen 5 1600 (12 потоков) + 16GB DDR4 (Windows 10)
- Intel Core i7 4770 (8 потоков) + 32GB DDR3 (Windows 10)
- Intel Core i5 3570 (4 потока) + 8GB DDR3 (Windows 10)
- Intel Xeon E5–2650 V2(16 потоков) + 32GB DDR3 (Windows 10)
Итого: 40 потоков
Командная строка с параметрами
ffmpeg -i "%SRC%" -an -pix_fmt yuv420p -f yuv4mpegpipe - | rav1e - -s 5 --quantizer 130 -y --output "%DST%
Результаты
Время кодирования: 55 минут
Размер видео: 75 мб
За качество говорить не буду, потому что подбор оптимальных параметров кодирования это задача дня заврашнего, а сегодня я преследовал цель добиться вменяемого времени кодирования и мне кажется это получилось. Я опасался, что склеенные кусочки склеятся плохо и будут дерганья в этих моментах, но нет, результат шел ровно, без каких-то рывков.
Отдельно отмечу, что для 1080p требуется около гигабайта оперативной памяти на поток, поэтому памяти должно быть много. Также замечу, что под конец стадо бежит со скоростью самого медленного барана и в то время как Ryzen и i7 уже давно закончили кодирование, Xeon и i5 еще продолжали пыхтеть над своими кусочками. Т.е. более длинное видео в целом кодировалось бы с большим общим fps за счет того, что более быстрые ядра успели бы сделать больше работы.
Запуская конвертацию на одном Ryzen 5 1600 с многопоточностью, максимум что я имел было около 1.5 fps. Здесь же, учитывая, что последние 10 минут кодирования — это добивка последних кусочков медленными ядрами, можно сказать, что получилось около 5–6 fps, что уже не так мало для такого продвинутого кодека. Вот и все, чем я хотел поделиться, надеюсь кому-нибудь это может пригодится.