Pull to refresh

Точное время: измеряем, применяем

Reading time 12 min
Views 40K
   Цель данной статьи – изложить, полученный в ходе работы над проблемой материал о способах максимально точного измерения времени и использования на практике этих способов, а также рассмотреть варианты управления чем-либо программным с максимально достижимой точностью.

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

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

   Программист, который уже пробовал разрабатывать различные прикладные приложения, например, связанные с передачей данных или с генерацией/анализом сигналов мог заметить, что все стандартные функции (sleep, beep, GetTickCount, таймеры) обладают большой погрешностью при работе с малыми значениями временного интервала. Это определено разрешением системного таймера, значение которого для разных компьютеров может несколько различаться. Узнать это разрешение можно, используя функцию GetSystemTimeAdjustment:

BOOL GetSystemTimeAdjustment(
    PDWORD lpTimeAdjustment, // size, in 100-nanosecond units, of a periodic time adjustment
    PDWORD lpTimeIncrement, // time, in 100-nanosecond units, between periodic time adjustments
    PBOOL lpTimeAdjustmentDisabled // whether periodic time adjustment is disabled or enabled
   );


   Разберем эту функцию для использования в Delphi. В lpTimeIncrement записывается значение разрешения системного таймера в единицах по 100 наносекунд. Нам нужно получить это значение, и вывести его, к примеру, в миллисекундах. Получится такая программка (см. пример 1):

program SysTmrCycle;

{$APPTYPE CONSOLE}

uses
  SysUtils, windows;

  var a,b:DWORD; c:bool;
begin
  GetSystemTimeAdjustment(a,b,c);
  WriteLn('System time adjustment: '+FloatToStr(b / 10000)+' ms.');
  WriteLn;
  Writeln('Press any key for an exit...');
  Readln;
end.


   Результат исполнения выводится на экран, у меня значение таймера оказалось равным 10,0144 миллисекунд.

   Что реально означает эта величина? То, что временные интервалы функций будут практически всегда кратны этой величине. Если это 10,0144 мс, то функция sleep(1000) вызовет задержку в 1001,44 мс. При вызове же sleep(5) задержка будет примерно 10 мс. Стандартный таймер Delphi, объект TTimer, естественно подвержен погрешности, но в еще большей степени. Объект TTimer основан на обычном таймере Windows, и посылает окну сообщения WM_TIMER, которые не являются асинхронными. Эти сообщения ставятся в обычную очередь сообщений приложения и обрабатываются, как и все остальные. Кроме того, WM_TIMER обладает самым низким приоритетом (исключая WM_PAINT), по отношению к другим сообщениям. GetMessage отправляет на обработку сообщение WM_TIMER лишь тогда, когда приоритетных сообщений в очереди больше не остается – сообщения WM_TIMER могут задерживаться на значительное время. Если время задержки превышает интервал, то сообщения объединяются вместе, таким образом, происходит еще и их утрата [1].
   Для того чтобы хоть как то производить замеры для сравнительного анализа функций задержки, необходим инструмент, позволяющий точно измерять временные интервалы выполнения некоторого участка кода. GetTickCount не подойдет ввиду вышеописанного. Но автор узнал об возможности опираться на частоту тактов процессора, за некоторый интервал времени. Начиная с Pentium III, процессоры обычно содержат достаточно доступный программистам счетчик меток реального времени, Time Stamp Counter, TSC, представляющий собой регистр на 64 разряда, содержимое которого с каждым тактом процессора инкрементируется [2]. Счет в счетчике начинается с нуля каждый раз при старте (или аппаратном сбросе) ЭВМ. Получить значение счетчика в Delphi можно следующим образом (см. пример 2):

program rdtsc_view;

{$APPTYPE CONSOLE}

uses
  SysUtils, windows;

function tsc: Int64;
var ts: record
 case byte of
  1: (count: Int64);
  2: (b, a: cardinal);
 end;
begin
 asm
  db $F;
  db $31;
  mov [ts.a], edx
  mov [ts.b], eax
 end;
 tsc:=ts.count;
end;

begin
 repeat WriteLn(FloatToStr(tsc)) until false;
end.


   Здесь ассемблерная вставка помещает результат счетчика в регистры edx и eax, значение которых затем переносится в ts, откуда доступно как ts.count типа Int64. Приведенная программа непрерывно выводит в консоль значения счетчика. На некоторых версиях Delphi есть готовая команда rdtsc (read time stamp counter), позволяющая сразу получить значение счетчика функцией RDTSC [3] вот так:

function RDTSC: Int64; register;
asm
 rdtsc
end;


   Предположим, у нас есть значение счетчика, но как использовать его? Очень просто. Опираясь на то, что значение изменяется с постоянной частотой можно вычислять разницу в количестве тактов процессора после исследуемой команды и до нее:

a:=tsc;
Command;
b:=tsc-a;


   В b будет число тактов процессора, прошедшее за время исполнения Command. Но тут есть один момент. Вызов tsc, дающий нам число тактов сам должен тоже затрачивать на это какое то количество тактов. И, для верности результата, его нужно вносить, как поправку, вычитаемую из полученного количества тактов:

a:=tsc;
C:=tsc-a;
a:=tsc;
Command;
b:=tsc-a-C;


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

function calibrate_runtime:Int64;
var i:byte; tstsc,tetsc,crtm:Int64;
begin
 tstsc:=tsc;
 crtm:=tsc-tstsc;
 for i:=0 to 9 do
  begin
   tstsc:=tsc;
   crtm:=tsc-tstsc;
   if tetsc<crtm then crtm:=tetsc;
  end;
 calibrate_runtime:=crtm;
end;


   Теперь, когда у нас есть необходимый инструмент, поэкспериментируем с функциями задержки. Начнем со всем известной и всеми применяемой sleep:

procedure Sleep(milliseconds: Cardinal); stdcall;

   Чтобы провести проверку точности задержки, включим в нашу консольную программу, кроме кода tsc и кода calibrate_runtime следующий код:

function cycleperms(pau_dur:cardinal):Int64;
var tstsc,tetsc:Int64;
begin
 tstsc:=tsc;
 sleep(pau_dur);
 tetsc:=tsc-tstsc;
 cycleperms:=(tetsc-calibrate_runtime) div pau_dur;
end;


   Этот код мы вызовем из программы, задавая по нескольку раз разные значения pau_dur (паузы).Если вы обратили внимание, число тактов за время паузы затем делится на значение паузы. Так мы узнаем точность задержки в зависимости от ее времени. Для удобства проведения теста и вывода на экран/сохранения результата теста применен такой код (см. пример 3):

var test_result,temp_result:string; n:cardinal; i:byte; aver,t_res:Int64; res:TextFile;
begin
 WriteLn('The program will generate a file containing the table of results of measurements of quantity of cycles of the processor in a millisecond. Time of measurement is chosen'+' miscellaneous, intervals: 1, 10, 100, 1000, 10000 ms. You will see distinctions of measurements. If an interval of measurement longer - results will be more exact.');
 WriteLn;
 Writeln('Expected time of check - 1 minute. Press any key for start of the test...');
 ReadLn;
 temp_result:='Delay :'+#9+'Test 1:'+#9+'Test 2:'+#9+'Test 3:'+#9+'Test 4:'+#9+'Test 5:'+#9+'Average:';
 n:=1;
 test_result:=temp_result;
 WriteLn(test_result);
 while n<=10000 do
  begin
   temp_result:=IntToStr(n)+'ms'+#9;
   aver:=0;
   for i:=1 to 5 do
    begin
     t_res:=cycleperms(n);
     aver:=aver+t_res;
     temp_result:=temp_result+IntToStr(t_res)+#9;
    end;
   WriteLn(temp_result+IntToStr(aver div 5));
   test_result:=test_result+#13+#10+temp_result+IntToStr(aver div 5);
   n:=n*10;
  end;
 WriteLn;
 AssignFile(res,'TCC_DEF.xls');
 ReWrite(res);
 Write(res,test_result);
 CloseFile(res);
 WriteLn('The test is completed. The data are saved in a file TCC_DEF.xls.');
 Writeln('Press any key for an exit...');
 ReadLn;
end.


   В нем мы исполняем cycleperms по пять раз для каждого временного интервала (от 1 до 10000 миллисекунд), а также считаем среднее значение. Получается таблица. Итак, полученные числа тактов процессора в ходе такого исследования:
TCC_DEF

   Картину мы наблюдаем не самую лучшую. Поскольку частота процессора  примерно 1778,8 МГц (см. пример 4), то значения тактов за 1 миллисекунду должны стремиться к приблизительному числу 1778800. Точность функции sleep не дает нам этого ни за 1, 10, 100 или 1000 миллисекунд. Только за десятисекундный промежуток времени значения близки. Пожалуй, если бы в тесте 4 не было 1781146, то усредненная величина была бы приемлемой.
   Что можно сделать? Оставить функцию и рассмотреть что-то еще? Пока не стоит торопиться. Я узнал, что можно вручную задавать погрешность отсчета эталонного интервала времени, используя функцию timeBeginPeriod [2]:

MMRESULT timeBeginPeriod(
    UINT uPeriod
   );


   Для поддержания такого высокоточного разрешения используются дополнительные системные ресурсы, поэтому нужно вызывать timeEndPeriod для их высвобождения по завершению всех операций. Код функции cycleperms для исследования такого sleep (см. пример 5):

function cycleperms(pau_dur:cardinal):Int64;
var tstsc,tetsc:Int64;
begin
 timeBeginPeriod(1);
 sleep(10);
 tstsc:=tsc;
 sleep(pau_dur);
 tetsc:=tsc-tstsc;
 timeEndPeriod(1);
 cycleperms:=(tetsc-calibrate_runtime) div pau_dur;
end;


   Еще есть малообъяснимая особенность, timeBeginPeriod(1), устанавливающая разрешение в 1 миллисекунду начинает давать эффект не сразу, а только после вызова sleep, поэтому в код, после timeBeginPeriod вставлено sleep(10). Результаты этого исследования:
TCC

   Наблюдаемые данные гораздо лучше. Среднее значение за 10 секунд довольно точно. Среднее за 1 миллисекунду отличается от него всего на 1,7 %. Соответственно отличия за 10 мс составляет 0,056 %, за 100 мс – 0,33 % (странно вышло), за 1000 мс – 0,01 %. Меньший, чем 1 мс интервал, невозможно использовать в sleep. Но можно твердо сказать, что sleep годна для пауз в 1 мс при условии выполнения timeBeginPeriod(1), и точность sleep только растет с ростом задаваемого временного промежутка (см. пример 6).

   Функция sleep основана на Native API функции NtDelayExecution, которая имеет следующий вид [5]:

NtDelayExecution(
  IN BOOLEAN              Alertable,
  IN PLARGE_INTEGER       DelayInterval );


   Попробуем провести тест ее задержек, подобно sleep, но учитывать будет она даже микросекунды:

function cyclepermks(pau_dur:Int64):Int64;
var tstsc,tetsc,p:Int64;
begin
 p:=-10*pau_dur;
 tstsc:=tsc;
 NtDelayExecution(false,@p);
 tetsc:=tsc-tstsc;
 cyclepermks:=(tetsc-calibrate_runtime) *1000 div pau_dur;
end;


   Эта функция не прописана в windows.pas или ином другом файле, потому вызовем ее, добавив строку:

procedure NtDelayExecution(Alertable:boolean;Interval:PInt64); stdcall; external 'ntdll.dll';

   Код, в котором мы вызываем функцию и строим таблицу результатов, следует подкорректировать вот так (см. пример 7):

var test_result,temp_result:string; n:Int64; i:byte; aver,t_res:Int64; res:TextFile;
begin
 WriteLn('The program will generate a file containing the table of results of measurements of quantity of cycles of the processor in a mikrosecond. Time of measurement is chosen'+' miscellaneous, intervals: 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000 mks. You will see distinctions of measurements. If an interval of measurement longer - results will be more exact.');
 WriteLn;
 Writeln('Expected time of check - 1 minute. Press any key for start of the test...');
 temp_result:='Delay :'+#9+'Test 1:'+#9+'Test 2:'+#9+'Test 3:'+#9+'Test 4:'+#9+'Test 5:'+#9+'Average:';
 n:=1;
 test_result:=temp_result;
 WriteLn(test_result);
 while n<=10000000 do
  begin
   temp_result:='10^'+IntToStr(length(IntToStr(n))-1)+'mks'+#9;
   aver:=0;
   for i:=1 to 5 do
    begin
     t_res:=cyclepermks(n);
     aver:=aver+t_res;
     temp_result:=temp_result+IntToStr(t_res)+#9;
    end;
   WriteLn(temp_result+IntToStr(aver div 5));
   test_result:=test_result+#13+#10+temp_result+IntToStr(aver div 5);
   n:=n*10;
  end;
 WriteLn;
 AssignFile(res,'TCC_NTAPI.xls');
 ReWrite(res);
 Write(res,test_result);
 CloseFile(res);
 WriteLn('The test is completed. The data are saved in a file TCC_NTAPI.xls.');
 Writeln('Press any key for an exit...');
 ReadLn;
end.


   После проведения исследования задержек, создаваемых NtDelayExecution получились интересные результаты:
TCC_NTAPI

   Видно, что применять такую точность этой функции бесполезно на промежутках менее 1 миллисекунды. Прочие интервалы задержек несколько лучше, чем у sleep без измененного разрешения, но хуже, чем с высоким разрешением sleep (в принципе это понятно, ведь тут мы не создавали потоков с повышенным приоритетом, и вообще не делали ничего для повышения точности, подобно тому, как это делает timeBeginPeriod). А если добавить timeBeginPeriod? Посмотрим, что получится:
NTAPI2

   На микросекундных интервалах ситуация все та же. А вот на интервалах, начиная с 1 миллисекунды отличие, относительно 10-секундного значения составляет 0,84 %, что лучше аналогичного использования sleep (1,7 %) –  NtDelayExecution дает задержку точнее.
   При поиске средств программирования задержек в исполнении кода был найден еще один вариант [4], вроде бы предоставляющий возможность указывать интервал в микросекундах. Это WaitableTimer. Работать с ним можно через функции CreateWaitableTimer, SetWaitableTimer, WaitForSingleObjectEx. Вид процедуры cyclepermks, куда мы добавили WaitableTimer:

function cyclepermks(pau_dur:Int64):Int64;
var tstsc,tetsc,p:Int64; tmr:cardinal;
begin
 tmr:=CreateWaitableTimer(nil, false, nil);
 p:=-10*pau_dur;
 tstsc:=tsc;
 SetWaitableTimer(tmr, p, 0, nil, nil, false);
 WaitForSingleObjectEx(tmr, infinite, true);
 CloseHandle(tmr);
 tetsc:=tsc-tstsc;
 cyclepermks:=(tetsc-calibrate_runtime2) *1000 div pau_dur;
end;


   Особенность применения WaitableTimer требует от нас также модификации расчета поправки, получаемой в calibrate_runtime:

function calibrate_runtime2:Int64;
var i:byte; tstsc,tetsc,crtm, p:Int64; tmr:cardinal;
begin
 tstsc:=tsc;
 crtm:=tsc-tstsc;
 for i:=0 to 9 do
  begin
   tmr:=CreateWaitableTimer(nil, false, nil);
   p:=0;
   tstsc:=tsc;
   SetWaitableTimer(tmr, p, 0, nil, nil, false);
   CloseHandle(tmr);
   crtm:=tsc-tstsc;
   if tetsc<crtm then crtm:=tetsc;
  end;
 calibrate_runtime2:=crtm;
end;


   Ведь SetWaitableTimer и CloseHandle тоже исполняются за период учитываемого нами количества тактов процессора. Сразу добавим в код cyclepermks вызов timeBeginPeriod, надеясь на помощь этой процедуры в приросте точности (см. пример 8). Таблица результатов:
TCC_WFSO

   Увы, и здесь мы не получили возможность устанавливать задержки для промежутков меньше миллисекундных. Разница значений 1 миллисекунды и 10 секунд равна 5%. В сравнении с предыдущими способами, это хуже.
   Перед тем, как делать выводы, скажу немного о собственно самом измерении времени. В приведенных исследованиях основой сравнений было число тактов процессора и у каждого компьютера оно разное. Если понадобится привести его к единицам времени на основе секунд, то нужно сделать следующее: применяя 10-секундную задержку NtDelayExecution получить число тактов процессора за эти 10 секунд или узнать длительность одного такта (см. пример 9). Зная количество тактов процессора в единицу времени, можно спокойно преобразовывать меньшие значения числа тактов процессора в значения времени. Кроме этого рекомендуется установить приложению приоритет реального времени.

   Заключение. В результате проведенной работы было установлено, что можно очень точно (даже до отрезка времени, исчисляемого 50 тактами процессора) замерять время на ЭВМ. Эта задача решена успешно. Что же касается возможности самостоятельно задавать точные задержки в исполняемом коде, то тут ситуация такова: лучший обнаруженный метод, позволяет сделать это с разрешением не большим, чем 1 миллисекунда, с погрешностью разрешения на интервале 1 мс порядка 0,84 %. Это функция NtDelayExecution с установкой разрешения процедурой timeBeginInterval. Недостаток функции, по сравнению с оказавшейся менее точной sleep это громоздкий вызов и нахождение в составе недостаточно документированного Native API. Использовать Native API не советуют по причине возможной несовместимости отдельных API в разных операционных системах семейства Windows. В общем, то, очевидное преимущество функции NtDelayExecution все-таки вынуждает сделать выбор в ее пользу.

Примеры:
 1. Определение разрешения системного таймера
 2. Вывод RDTSC
 3. Задаем интервал через sleep
 4. Узнаем частоту процессора
 5. Задаем интервал через sleep более точно
 6. Исследуем точность установки интервала через sleep на разных значениях
 7. Интервал с помощью NtDelayExecution
 8. Интервал посредством WaitableTimer
 9. Узнаем длительность одного процессорного такта
Примеры содержат файлы *.dpr исходного кода (на языке Delphi), скомпилированное консольное *.exe приложение и (некоторые) *.xls таблицу уже полученных автором результатов (в формате, поддерживаемом MS Excel). Все примеры – одним файлом.

Литература:
 1. Руссинович М., Соломон Д. Внутреннее устройство Microsoft Windows. – СПб.: Питер, 2005. – 992 с.
 2. Щупак Ю.А. Win32 API. Эффективная разработка приложений. – СПб.: Питер, 2007. – 572 с.
 3. RDTSC – Wikipedia [http://ru.wikipedia.org/wiki/Rdtsc]
 4. CreateWaitableTimer – MSDN [http://msdn.microsoft.com/en-us/library/ms682492(VS.85).aspx]
 5. NtDelayExecution – RealCoding [http://forums.realcoding.net/lofiversion/index.php/t16146.html]

   Статья была написана 13.11.2009, автор begin_end. Некоторые моменты, рассматриваемые в статье автор обсуждал со slesh’ем, которому выражается благодарность за такую помощь.
Tags:
Hubs:
+18
Comments 65
Comments Comments 65

Articles