19.10.2022

Возвращаясь к копированию файлов

Сделав первую версию программки для копирования файлов и даже получив какой-то положительный результат, правда, всего лишь в одном из множества экспериментов, я захотел вывести формулу, которая показывает, во сколько раз может быть ускорено копирование файлов в многопоточном варианте.

И вот в этом месте я застрял. Потому что по всем моим рассуждениям выходило, что существенного ускорения быть не должно. И в большинстве экспериментов выходило именно так, за исключением всего лишь одного.
В общем, долго я в фоновом режиме думал на эту тему, пока меня не осенило. Так что сейчас поделюсь результатами.

Начнем с последовательного копирования при отключенном кэшировании со стороны ОС. В этом случае время копирования файлов (замечу, что пока речь про огромные файлы) будет наибольшим и составит


 
 


где D – объем копируемых данных, Vr
– скорость чтения, Vw – скорость записи данных на соответствующих дисковых устройствах.

При параллельном копировании с использованием многопоточной архитектуры время копирования составит



 


где Vmin
минимальная из скоростей чтения или записи. Я считаю, что в этом случае время на переключение потоков можно не учитывать в расчетах, так как оно пренебрежимо мало из-за гораздо более высокой производительности процессора и ОЗУ по сравнению с дисковыми устройствами.
Из приведенной формулы понятно, что скорость параллельного копирования существенно превышает скорость последовательного.

Но это происходит ровно до того момента, пока в работу не вступает системный файловый кэш на уровне ОС. Такой кэш реализован в виде отдельного системного процесса и работает всегда независимо и параллельно пользовательскому процессу.
Фактически это приводит к тому, что последовательное копирование при наличии файлового кэша по факту превращается в параллельное и примерно соответствует ему по скорости.
Но всегда ли?

Давайте рассмотрим процесс копирования подробнее с разных сторон.
В том случае, когда скорость чтения с диска-источника меньше скорости записи, то именно она будет лимитирующим фактором и будет однозначно определять скорость копирования как при параллельном, так и при последовательном копировании с кэшем. В этом случае никакого преимущества у параллельного копирования нет (точнее, почти нет).

В случае, когда лимитирующим фактором является запись на диск назначения и размер буфера записи в системе кэширования достаточен, то мы опять же получаем (примерный) паритет по скорости копирования у обоих вариантов.

А вот когда буфера записи становится недостаточно, то есть когда объем копируемых данных во много раз превышает размер кэша, то псевдопараллельный режим переходит в чисто последовательный и скорость записи существенно падает.
Это связано с тем, что при заполнении буфера записи при запросе приложения на запись очередной порции данных оно вынуждено ждать, пока буфер записи не скинет самую старую порцию на диск и не освободит место в ОЗУ под новую.

Таким образом, один из двух случай, когда параллельное копирование может быть существенно быстрее, чем последовательное с использованием кэширования, возникает тогда, когда объем копируемых данных существенно превышает объем кэша и копирование производится с более быстрого устройства на более медленное. Точнее, объем копируемых данных должен превышать половину кэша, так как из всего доступного объема половина будет использоваться под чтение.
Впрочем, это ограничение на половину объема можно снизить, правильно указав флаги использования копируемых файлов.
Если отбросить простые алгебраические манипуляции, то в этом случае время копирования файлов составит



 

 

где B – размер буфера записи, k – коэффициент > 1, показывающий, во сколько раз скорость чтения больше скорости записи, то есть Vr = k*Vw.

Отсюда легко получаем ускорение параллельного копирования по отношения к последовательному с кэшем



 

 

Остался еще один небольшой нюанс, связанный с переходом от копирования очередного файла к следующему. Если в случае параллельного копирования никаких пауз в этот момент нет, то вот в случае последовательного, даже с использованием кэша, она все же возникает, потому что пока запись очередного файла не закончится до конца, то есть все данные не будут сброшены на устройство, последовательное приложение не может начать копировать следующий файл. 

Но насколько велика такая задержка? Приблизительно её можно оценить так



 


где С
– размер блока read-ahead в системе файлового кэширования.
Насколько же она велика? Это зависит от размера блока и может сильно отличаться в разных версиях и вариантах системы кэширования. В простейших случаях она имеет фиксированный размер и, например, для какой-то (уже не помню номер) версии Microsoft Windows Server составляет 256 КиБ.
В любом случае можно констатировать, что она не очень велика, что подтверждается и моим тестами. Например, когда скорость дисков источника и назначения сравнима, то выигрыш параллельного копирования очень невелик и примерно соответствует приведенной величине задержке, умноженной на количество копируемых файлов за вычетом единицы.
Поэтому в том случае, когда мы копируем небольшое количество значительных по размеру файлов, такую задержку можно не учитывать, как и было в расчета времени копирования выше.

Но только не тогда, когда мы копируем очень много мелких файлов. И это второй случай, когда параллельное копирование будет значительно опережать последовательное с кэшем.

К сожалению, пока моя текущая версия программы во втором случае не может особо помочь. Это связано с особенностями работы с файлам в режиме с отключенным кэшем: порции данных, которыми ведется такая работа, должна быть кратна размеру сектора устройства.
Для оптимизации работы с большими файлами я сейчас использую достаточно большой размер такой порции.
При записи концовки большого файла или всего крохотного файла сначала записывается полная порция данных, гораздо больше, чем нужно, а файл потом усекается до требуемого размера.
В принципе, для оптимизации нужно внести совсем немного исправлений, в следующей версии, я думаю, они уже появятся.

Ну а пока, в текущей версии, сделал немного более информативный интерфейс, а то он был совсем уже слепой, да пару косячков исправил.

Кстати, для копирования файлов в параллельном режиме без использования кэширования вполне достаточно очень небольшого буфера. Его размер примерно можно определить по скорости работы накопителей. Точнее это можно сделать по IOPS, но сути это не меняет.
Сейчас я, например, исхожу из того, что размер буфера должен быть равен сумме объемов данных всех устройств, задействованных в копировании, которые они могут передать за один цикл переключения потоков. В Windows он скорее всего равен 55 мс, но может быть и меньше.
Этого хватает с большим запасом для поддержания стабильной скорости, при этом объём памяти, занимаемой программой, не превышает 25 МиБ. Потенциально может иметь важное значение в тяжело нагруженных системах, интенсивно работающих с ОЗУ.

6 комментариев:

  1. > А вот когда буфера записи становится недостаточно, ..., то псевдопараллельный режим переходит в чисто последовательный

    Не уверен, что это так. Ведь если узкое место запись, то когда поток ждет места в буфере, ОС все еще продолжает писать, запись не прекращается, а именно она узкое место, чтение можно игнорировать. Фактически буфер - это блокирующая очередь. С одной стороны складываем блоки на запись, с другой достаем и пишем. Увеличение размера очереди/буфера позволяет сильнее развязать producer и consumer, но после определенного размера - это перестает оказывать влияние.

    > Если отбросить простые алгебраические манипуляции

    Это, прям как в учебнике физики, "и с помощью элементарных преобразований получаем..." и сидишь пару часов пытаясь понять какие элементарные преобразования использовали за ночь перед экзаменом :))). Что-то я так и не получил Вашу формулу.

    Мне кажется:
    - если Vw < Vr: Tc = b / Vr + D / Vw - сначала читаем 1-й блок, потом все время пишем
    - иначе Tc = D / Vr + b / Vw - постоянно читаем, а дальше чуть-чуть ждем пока запишется последний блок
    где b - размер блока чтения/кэша, ради упрощения здесь он одного размера

    > Но насколько велика такая задержка?

    А разве не будет она равна min(D, B) / (Vr - Vw) при  Vw < Vr? По сути ведь нужно записать все что было прочитано, но не успело записаться.

    В целом полное время копирования множества файлов наверное можно еще подсчитать следующим образом (для абстрактной системы не учитывая внутренности Windows):
    - если узкое место чтение, то чтение прерывается только при записи хвостов файлов, получаем  T = sum(Di) / Vr + N * b / Vw, где N - число файлов, а b - размер блока чтения
    - а если узкое место запись, то она прерывается только при чтении самого начала каждого файла, получаем: T = sum(Di) / Vw + N * b / Vr

    Или в общем случае: T = sum(Di) / min(Vr, Vw) + N * b / max(Vr, Vw)

    Но на деле наверное кэш работает чуток по другому и теория расходится с Вашей практикой.

    ОтветитьУдалить
    Ответы
    1. > Фактически буфер - это блокирующая очередь.
      Вот. Когда буфер полностью заполнится, он начнет блокировать приложение и оно в этот момент будет простаивать, не записывая и не читая. Когда приложение наконец-то получит управление и скинет данные на запись, ему придется ждать чтения (если при копировании мы используем достаточно большой внутренний буфер приложения, что логично при работе с большим объема данных). Ну и таким образом переходим в чисто последовательный режим.
      Время, когда буфер записи будет полностью заполнен B/(Vr-Vw) или В/(k*Vw-Vw). Ну и дальше простая алгебра.

      > Но на деле наверное кэш работает чуток по другому и теория расходится с Вашей практикой.
      Это верно. Скорее всего мою оценку можно считать приблизительной, так как реальные алгоритмы файлового кэширования не известны.
      Но наибольший приход кэширование дает тогда, когда файл многократно используется повторно, причем в некоторых, особо нужных в данное время местах. Ну и алгоритмы кэширования, видимо, именно под этот случай и оптимизируют в основном, а не под простое копирование файлов. Так что не думаю, что моя оценка будет очень сильно отличаться от реальности.

      Удалить
    2. > и оно в этот момент будет простаивать, не записывая и не читая.
      Да приложение блокируется, но ведь ОС продолжает писать из дискового кэша на диск параллельно, даже когда приложение заблокировано. И как только запишет достаточно грязных блоков, кэш вытеснит чистые блоки, приложение разблокируется, прочитает следующий блок и отдаст в кэш на запись. Запись на диск при этом не прекращается, так как грязных блоков в избытке.

      Удалить
    3. > Запись на диск при этом не прекращается, так как грязных блоков в избытке.
      Это верно. Но я не про это, а про то, что следующий блок приложение скинуть не может и ждет, пока очередной "грязный" блок не запишется на диск, при этом полностью остановившись, то есть не только не записывая, но и не считывая данные.
      Черт его знает, может, я и не прав, но тогда у меня вообще нет никаких объяснений полученному в одном эксперименте существенному ускорению.

      Опишу как я считал дальше, исходя из предположения, что после заполнения буфера записи приложение по сути начинает работать в последовательном режиме.
      tc = tb+tr+tw,
      где tb - время заполнение буфера записи (в предыдущем комментарии), tr и tw - время чтения и записи соответственно после заполнения буфера.
      Далее везде V - скорость записи, k*V - скорость чтения
      tw = (D - tb*V)/V = (D-B/(k-1))/V
      tr = (D - tb*V)/(k*V) = (D-B/(k-1))/(k*V) - и вот здесь, я, похоже, ошибся, ведь за время tb мы успеем прочитать гораздо больше данных.
      Должно быть так:
      tr = (D - tb*k*V)/(k*V).
      Это всё немного меняет, приводя вот к такому результату:
      tp/tc = (D + B*k - D*k^2)/(D*k - D*k^2).

      По поводу "простых" алгебраических манипуляций. Они и правда простые и я вначале честно пытался всё расписать на листочке. Но то знак потеряешь, то одно из подобных пропустишь... Плюнул, забил в Wolfram Mathematica, поэтому и результат такой немного "нечеловеческий". В том смысле, что в результате у нас и в числителе и знаменателе - отрицательные числа. Если бы делал вручную, обязательно они были бы положительные. На результат не влияет, но как-то красивше получает, нет?

      > А разве не будет она равна min(D, B) / (Vr - Vw) при Vw < Vr?
      Нет, ведь все равно нас лимитирует запись по большому счету. Из-за ожидания всё, что мы теряем по времени, это чтение одного read-ahead блока в начале копирования следующего файла. Размер такого блока я обозначил С, и он намного меньше, чем размер всего буфера записи (или чтения) в целом, так как собственно буфер и состоит из набора большого количества таких элементарных блоков. D - это размер всех файлов вместе взятых.
      Поэтому в моей терминологии время на копирование большого количества мелких файлов составит
      D/V + C*n/(k*V), где n - количество мелких файлов. Впрочем, это не основная тема данного поста, поэтому я углубляться особо не стал.

      Удалить
    4. > при этом полностью остановившись, то есть не только не записывая, но и не считывая данные.
      Ну и ладно, ОС ведь продолжает грязные блоки из кэша писать на диск, а это главное, ведь мы рассматриваем случай когда запись узкое место.

      Можно рассмотреть ситуацию на примере жидкости. Копирование файлов - это переливание водны из одной бутылки в другую, а буфер ОС - это воронка. Можно взять большую воронку, и вылить всю бутылку в нее, а можно поменьше, но с "носиком" такого же диаметра, и постепенно доливать, главное чтобы воронка не опустошалась полностью. Размер воронки никак не повлияет на скорость заполнение второй бутылки. Скорость зависит только от диаметра ее узкой части. На деле конечно еще от высоты столба жидкости, но отбросим эти детали, не применимые к изначальной задачи :)

      > Это всё немного меняет
      Ага, что-то похожее я получил, но не уверен что это правильная формула, как описал выше. Как мне кажется файл всегда пишется, но иногда не читается, tw - включает tr, а значит tr можно игнорировать.

      > но тогда у меня вообще нет никаких объяснений полученному в одном эксперименте существенному ускорению.
      А что за эксперимент?

      > Но то знак потеряешь, то одно из подобных пропустишь
      Это точно, а еще у меня практики такой больше нет и туплю на элементарных моментах, как-то даже формулу решения квадратного уравнения пришлось в википедии смотреть :(

       > и результат такой немного "нечеловеческий"
      Ага, на это обратил внимание, подумал как-то не аккуратненько :)))

      > всё, что мы теряем по времени, это чтение одного read-ahead блока в начале копирования следующего файла
      Согласен, я просто по тексту подумал Вы о другом: по окончанию записи последнего блока и закрытии файла, приложение блокируется пока все данные не запишутся на диск и в формуле Вы считали именно эту задержку, но она в принципе не влияет на полное время, так как запись - узкое место. Я хотел ее посчитать, но на деле посчитал что-то другое, моя формула не верная.

      > D/V + C*n/(k*V)
      Ага с этой формулой согласен, но мне кажется она справедлива для файлов любого размера, даже если дисковый кэш полностью заполняется.

      Удалить
    5. > Копирование файлов - это переливание водны из одной бутылки в другую, а буфер ОС - это воронка.
      Точнее, тогда тут нужны две воронки. Ведь у нас исходно два файла, соответственно, один кэшируется на чтение в одну воронку, второй на запись - из второй воронки, а приложение - это насос, который из первой во вторую воронку перекачивает.

      > А что за эксперимент?
      В прошлом посте на эту тему описывал https://kvyprog.blogspot.com/2022/05/blog-post.html. Хотя там тоже не понятно, вроде бы эффект получался при копировании в обе стороны. Разве что HDD на запись работает медленнее, чем на чтение.
      В общем, куча вопросов и нет ответов. Где-то через недельку буду много (относительно) данных переписывать, потестирую еще раз.

      > мне кажется она справедлива для файлов любого размера...
      Согласен.

      Удалить