текст - Математико-механический факультет СПбГУ

advertisement
САНКТ-ПЕТЕРБУРГСКИЙ ГОСУДАРСТВЕННЫЙ УНИВЕРСИТЕТ
Математико-механический факультет
Кафедра системного программирования
Терехин Николай Александрович
Компьютерная обработка числовых потоков
с помощью вэйвлетов
Дипломная работа
Зав. кафедрой:
д. ф.-м. н., профессор А.Н. Терехов
Научный руководитель:
д. ф.-м. н., профессор Ю.К. Демьянович
Рецензент:
Санкт-Петербург
2011 г.
St. Petersburg State University
Faculty of Mathematics and Mechanics
Chair of Software Engineering
Terekhin Nikolay Aleksandrovich
Computer treatment of numerical flows with wavelets
Graduate paper
Head of the chair:
Dr. of Phys. and Math. Sci., Professor A.N. Terehov
Scientific advisor:
Dr. of Phys. and Math. Sci., Professor Y.K. Demjanovich
Reviewer:
St. Petersburg
2011
2
Оглавление
1
Введение .............................................................................................................................................. 4
2
Математическая модель..................................................................................................................... 8
3
2.1
Введение ...................................................................................................................................... 8
2.2
Сплайны........................................................................................................................................ 9
2.3
Калибровочные соотношения ..................................................................................................10
2.4
Всплесковое (вэйвлетное) разложение...................................................................................13
2.5
Частный случай ..........................................................................................................................15
2.6
Алгоритм выбора сетки.............................................................................................................15
Реализация .........................................................................................................................................18
3.1
Выбор средств разработки .......................................................................................................18
3.2
Интерфейс ..................................................................................................................................21
3.3
Краткое описание реализации .................................................................................................26
3.4
Возможности распараллеливания ...........................................................................................28
4
Заключение ........................................................................................................................................33
5
Список литературы ............................................................................................................................34
3
1 Введение
Современные потоки информации в процессе обработки, хранения и передачи имеют электронную форму: чаще всего это последовательности нулей и единиц
огромной длины (1010  1012 символов). Такие последовательности можно быстро
обрабатывать лишь в случае, когда имеются большие компьютерные ресурсы
(быстродействие, память, мощные каналы связи). Задача сокращения объемов цифровой информации за счет отбрасывания несущественных ее составляющих весьма
актуальна, причем степень важности эффективного решения этой задачи постоянно возрастает.
На первом месте среди средств решения этой задачи несомненно находятся
вэйвлеты, что подтверждается большим числом приложений в различных технических и научных областях. К настоящему времени теория вэйвлетов завоевала
прочные позиции в математике и нашла глубокие приложения в физике, астрономии, медицине, и, конечно, в инженерном деле, поскольку основной результат этой
теории — эффективные алгоритмы обработки больших потоков информации. Под
эффективностью в данном случае понимают экономное (с точки зрения экономии
ресурсов компьютера: памяти и времени обработки) разложение потока информации на составляющие, так чтобы можно было выделить основной информационный
поток, уточняющий информационный поток и информационный поток с несущественной информацией. Как правило, основной информационный поток значительно менее плотный, чем исходный поток информации; поэтому его можно передать
быстро, и при этом не требуется использовать линии связи с широкой полосой пропускания и с большим количеством проводников. Уточняющий информационный
поток не во всех случаях необходим, его можно передавать фрагментарно в зависимости от потребностей. Наконец, поток с несущественной информацией вообще
может быть отброшен. Конечно, вопрос о том, какая информация является основной, какая уточняющей, а какая — несущественной, выходит за рамки математических исследований и должен решаться в каждом отдельном случае специалистом
предметной области.
4
Роль теории вэйвлетов (всплесков) состоит в том, что она дает предметному специалисту достаточно широкий арсенал средств, из которых он может выбрать то
средство, которое ему подходит для обработки (для разложения на составляющие)
интересующего его потока информации. Такими средствами в теории вэйвлетов являются наборы вложенных (основных) пространств функций и их представлений в
виде прямой (а иногда и ортогональной) суммы вэйвлетных пространств. Весьма
важными являются базисы основных пространств, а также базисы вэйвлетов
(всплесков); построению и изучению свойств таких базисов посвящено много работ.
Для более наглядной иллюстрации идеи вэйвлет-преобразования представим себе, что рассматриваемый числовой поток кодирует некоторое изображение, выводимое на экран компьютера (или цифрового телевизора). Предположим, что экран
представляет собой прямоугольную матрицу из большого числа пикселей — маленьких прямоугольников, нанесенных на прозрачную поверхность (стекло), которые светятся под воздействием попадающих на них электронов, причем для такого
свечения имеется фиксированное число градаций яркости. Для простоты рассматриваем лишь одноцветные изображения (черно-белый экран). Обычно пиксели перенумерованы последовательно по строкам, которые предварительно выстроены одна
за другой в прямую линию; таким образом, пиксели приобретают номера
0,1, 2,..., N  1 , где N  M  K , где M
число строк рассматриваемой матрицы, а K —
число ее столбцов. Для определенности будем считать N четным; пусть N  2L , где
L — натуральное число. Каждому пикселю предписывается определенная яркость,
выражаемая некоторым числом; обозначим это число для j -го пикселя через c j . Таким образом, кодировка изображения производится с помощью числового потока
с0 , с1 , с2 ,..., с2 L1.
Поток {c j } может быть передан по линиям связи и при подаче на экран компьютера (телевизора) может быть превращен в исходное изображение. Если исходное
изображение передается с большой точностью, то N весьма велико, и передача даже
5
одного такого изображения представляет значительные технические трудности. Поэтому возникает задача уменьшения количества передаваемых чисел.
Идея вэйвлетного подхода иллюстрируется следующим образом. Из числового потока {c j } формируется два числовых потока:
aj 
(c2 j  c2 j 1 )
2
, bj 
(c2 j  c2 j 1 )
2
,
где j  0,1,..., L  1 .
Нетрудно видеть, что
с2 j  a j  b j ,
с2 j 1  a j  b j , j  0,1,..., L  1
(1)
А значит вместо {c j } можно передать два потока {a j } и {b j } . Возникает вопрос, в чем
же польза от замены исходного потока на два, если общее количество чисел в них
совпадает с количеством чисел в {c j } . Для ответа на этот вопрос заметим, что если
соседние числа в {c j } близки, то второй из потоков {b j } состоит из чисел, близких к
нулю, так что может оказаться, что второй поток вообще не нужен, и его можно отбросить. Однако, если некоторые фрагменты первого потока {a j } не дают достаточной точности, то можно использовать соответствующие фрагменты (с теми же диапазонами индексов) второго потока, и произвести расчеты по формулам (1); это приведет к точному восстановлению исходного потока на соответствующих участках.
Поток чисел
a0 , a1 , a2 ,..., aL1
называют основным, а поток чисел
b0 , b1 , b2 ,..., bL1
называют вэйвлетным (всплесковым) потоком.
6
Полученный основной поток можно рассматривать как сжатие исходного
потока, а поток {b j } как поправку к основному потоку, позволяющую восстановить исходный поток.
Если поток {a j } все еще велик для передачи, то аналогичной процедурой его
расщепляют на два потока. Таким образом возможно дальнейшее продолжение
процесса расщепления; на k -м шаге получим расщепление исходного потока на
k  1 потоков: нулевой поток (основной результат сжатия) и k вэйвлетных пото-
ков, последовательное добавление которых к нулевому потоку приводит к последовательному уточнению результата сжатия вплоть до полного восстановления исходного потока.
В постановке задачи о разложении гладкого потока на вход непрерывная
функция, а в результате нужно выдать её аппроксимацию.
Входная функция может быть задана аналитически или таблицей значений.
Во втором случае предполагается, что таблица достаточно плотная (в частности
производные должны вычисляться с большой точностью). Необходимо аппроксимировать функцию на заданном отрезке. На нём некоторым образом выбирается сетка, затем производится вычисление значений и производных в узлах, и с
их помощью исходная функция разлагается в сумму базисных. После этого для
уменьшения объема данных сетка итерационно укрупняется, количество базисных функций уменьшается до некоторого значения. На каждом шаге коэффициенты пересчитываются по специальным формулам, новые коэффициенты являются результатом работы алгоритма. Принимающая сторона может с их помощью приближённо вычислить исходную функцию в любой точке.
Конечной целью работы является реализация разложения числового потока с
помощью вэйвлетов, описанных в [2].
7
2 Математическая модель
2.1 Введение
Ключевым понятием для рассматриваемого алгоритма является определение
сплайна. Сплайн является составной функцией. Область определения этой
функции разбивается на несколько интервалов, на каждом их которых сплайн
совпадает с какой-либо достаточно простой функцией, например, полиномом. В
местах стыка этих функций часто обеспечивается необходимая гладкость.
Например, f ( x)  | x | совпадает с линейной функцией на отрезках [1, 0] и [0,1] .
Но в точке x  0 гладкости нет – есть только непрерывность.
Интерполяционная задача Эрмита – это способ приближения функции по
значениям и производным функции в некоторых точках. Таким образом, необходимо построить функцию, имеющую в данных узлах заданные значения и
производные. Первое, что приходит в голову, – использовать полином минимальной степени. Он существует и является единственным, называется такой
полином многочленом Эрмита. В реальных условиях данный метод плох из-за
того, что вычисление значения полинома в каждой конкретной точке довольно
трудоемко и занимает много времени. Это объясняется тем, что узлов в реальной задаче будет много, соответственно, степень полинома будет большая.
В рассматриваемом алгоритме предлагается использовать сплайны. Множество линейных комбинаций сплайнов образует пространство сплайнов. Далее
будут рассмотрены вэйвлетные (всплесковые) разложения таких пространств.
В качестве области определения рассмотрим ( ,  ) 
[a, b]  ( ,  )
, для любого отрезка
достаточно рассмотреть сужение функции на данный отрезок.
Математический аппарат подробно изложен в [2], алгоритм выбора сетки
взят из [1].
8
2.2 Сплайны
Пусть X – сетка вида
X :...  x1  x0  x1  ...;
  lim x j ,   lim x j .
j 
j 
Введем трехкомпонентную вектор-функцию (столбец)  :( ,  )
3
класса
C 2 ( ,  ) , вронскиан из компонент которой при t  ( ,  ) равномерно отделен от
нуля:
det( (t ),  (t ),  (t ))  c  0 .
Это некоторая заранее оговоренная функция, не имеющая отношения к
входным данным. Можно считать её параметром алгоритма.
Пусть k   ( xk ),
k   ( xk ) .
Рассмотрим символический определитель (вектор, являющийся линейной
комбинацией  j 1 и  j 1 )
 j 1
 j 1


aˆ ( j 1 ,  j 1 ,  j  2 ,  j  2 )  det 
.
 det( j  2 ,  j  2 ,  j 1 ) det( j  2 ,  j  2 ,  j 1 ) 
Координатные B ( X ) -сплайны второго порядка  j (t ) определяются аппроксимационными соотношениями:
ak 2k 2 (t )  ak 1k 1 (t )  akk (t )   (t )
t  ( xk , xk 1 ), k  ,
(2)
при условиях
supp  j  [ x j , x j 3 ],
(3)
a j  aˆ ( j 1 ,  j 1 ,  j  2 ,  j  2 ).
Для достаточно мелкой сетки X система (2) однозначно разрешима, а получаемое решение  j лежит в пространстве C1 ( ,  ) :
9
k  2 (t ) 
det( (t ), ak 1 , ak )
,
det(ak  2 , ak 1 , ak )
k 1 (t ) 
det(ak  2 ,  (t ), ak )
,
det(ak  2 , ak 1 , ak )
k (t ) 
det(ak  2 , ak 1 ,  (t ))
.
det(ak  2 , ak 1 , ak )
Откуда получаем
 det(a j  2 , a j 1 ,  (t ))
t  ( x j , x j 1 ),

det(
a
,
a
,
a
)
j

2
j

1
j

 det(a j 1 ,  (t ), a j 1 )

t  ( x j 1 , x j  2 ),
 j   det(a j 1 , a j , a j 1 )
 det( (t ), a , a )
j 1
j2

t  ( x j  2 , x j 3 ),
 det(a j , a j 1 , a j  2 )

0
t  ( x j , x j 3 ).

(4)
Линейная оболочка S( ,  ) ( X ,  ) функций  j

S( ,  ) ( X ,  )  u | u   c j j
j

c j 
1

, j 

представляет собой бесконечномерное пространство B -сплайнов второго порядка на интервале ( ,  ) ; при этом
S( ,  ) ( X ,  )  С1 ( ,  ) .
2.3 Калибровочные соотношения
Пусть имеется некоторая сетка. Тогда можно построить сплайны по формулам (4). Любую гладкую функцию можно аппроксимировать суммой
функционального ряда с членами вида c j j (t ) . Если говорить формально, то
входная функция проецируется на S( ,  ) ( X ,  ) , а результат можно разложить
по главному базису. Хотя слагаемых будет бесконечно много, для конкретно-
10
го t лишь конечное число слагаемых будет отлично от 0, благодаря свойству
(3). Как получить коэффициенты будет показано дальше.
Чтобы приближение получилось качественным, исходная сетка должна
быть достаточно мелкой. При этом нас может устроить и качество приближения с более крупной сеткой (и меньшим числом коэффициентов).
Рассмотрим процесс выкидывания одного узла. Положим
 xj ,
xj  
 x j 1 ,
j  k,
j  k  1,
и рассмотрим новую сетку
Xk :
...  x1  x0  x1  ... .
Аналогично предыдущему определим функции  j для сетки X k . Система
функций { j } j , несомненно зависит от k , но для краткости не будет отмечать эту зависимость.
Введем вектор-столбцы bsT с помощью тождеств
bsT x  det( s ,  s , x)
x 
3
,s
.
Очевидно, что для j  \{k  2, k  1, k} и t  ( ,  ) сплайны  j совпадают с
рассмотренными ранее сплайнами:
 j (t ), j  k  3,
j  
 j 1 (t ), j  k  1.
11
При t  ( ,  ) справедливы следующие тождества:
k 2 (t )  k 2 (t )  c1k k 1 (t ),
k 1 (t )  c2k k 1 (t )  c3k k (t ),
k (t )  c4k k 1 (t )  k (t )
t  ( ,  ),
где
c1k 
причем
bkT 2 ak 1
,
bkT 2 ak 2
c2k 
коэффициенты
bkT1ak 1
,
bkT1ak 1
c1 k
и
c3k 
c2k
bkT3ak
bkT ak
,
c

,
 4 k
bkT3ak
bkT ak  2
определяются
векторами
k 1 , k , k 1 , k 2 , k , k 1 , k 2 , а коэффициенты c3k и c4k определяются вектора-
ми k , k 1 , k 2 , k 3 , k 1 , k 2 , k 3 .
Для функционала f  (C s )* будем писать supp f  [c, d ] , если значение f , u
определяется значениями функции u  C s на интервале (c, d ) .
В рассматриваемых предположениях для того, чтобы система функционалов  gi 

i
из  C1  ,    была биортогональна системе функций  j  j ,
*
необходимо и достаточно, чтобы gi ,  ai для любого i  . При каждом
фиксированном  0,1, 2 в
 C  ,   
1
*
существуют функционалы g j 
со
свойствами
g j  ,  j   j , j
 j, j  ,
supp gi    x j  , x j    
  0 .
Для доказательства сделанных выше утверждений можно обратиться к [2].
12
2.4 Всплесковое (вэйвлетное) разложение
Рассмотрим оператор Pk  проектирования пространства S ,    X ,   на
подпространство S ,    X k ,   , задаваемый формулой
Pk  u   g j  , u  j
u  S  X ,  
(5)
j
и введем оператор
Qk   I  Pk  ,
где I – тождественный в S  X ,   оператор.
Пространством вэйвлетов (всплесков) называется пространство
Wk   Qk  S  X ,   .
Итак, получаем прямое разложение


S  X ,    Sk X ,   Wk  ,
(6)
которое называется сплайн-вэйвлетным разложением пространства S  X ,   .
Пусть u  S  X ,  . Используя соотношение (6), получаем два представления элемента u :
u   c j j ,
(7)
j
u   ai  i   b j   j ,
i
(8)
j
13
где
ai   gi  , u ,
bj  , c j 
1
.
(9)
Из (6)-(8) получаем
c j   ai  pi , j  b j 
(10)
i
Формулы (10) называются формулами реконструкции.
Используя представление (9), перепишем (10) в виде
c j   gi  , u pi , j  b j 
i
И подставим u из соотношения (7) (заменив в (5) индекс суммирования j на s ):
c j   cs qis  pi , j  b j  ,
i
s
где
qis   gi  , s .
Отсюда следует
b j   c j   cs qis  pi , j .
(11)
ai    cs qis  , s .
(12)
i
s
Подставляя (7) в (9), имеем
s
Формулы (11) и (12) называются формулами декомпозиции.
14
2.5 Частный случай
Выберем функцию   t   1, t , t 2  . По формуле (4) можно получить предT
ставления сплайнов в более простом виде
2

t  x0 


t  ( x0 , x1 ),

2  x1  x0  x2  x1  x2  x0 

 2
 t  x3  x2  x1  x0   2t  x3 x2  x1 x0    x0 x2 x3  x1 x2 x3  x0 x1 x2  x0 x1 x3  t  ( x , x ),
1
2

2
j  
2  x2  x1   x2  x0  x3  x1 

2
t  x3 



t  ( x2 , x3 ),

2  x3  x2  x2  x1  x3  x1 


0
t  ( x0 , x3 ),
где xi  x j i .
2.6 Алгоритм выбора сетки
Предположим, что исходная сетка  x j  — равномерная: xi  ih . Здесь будут предложены формулы для количества выбрасываемых узлов мелкой сетки  x j  , следующих за очередным сохраняемым узлом xi упомянутой сетки.
За сохраненным узлом xi следует сохраняемый узел xi  s ; таким образом, узлы в количестве s  1 выбрасываются.
Укрупненная сетка  x j  должна удовлетворять условию:
( x j 1  x j ) f ( x j )  C ,
(13)
где C – некоторая константа. Левая часть равенства (13) приближает приращение функции f на отрезке  x j , x j 1  . Следовательно, логика построения
укрупненной сетки в том, чтобы на каждом интервале между соседними узлами функция изменялась на одну и ту же константу C .
15
Предполагая, что укрупненная сетка содержит M узлов и, складывая предыдущие равенства, имеем
 (x
j 1
 x j ) f ( x j )  CM ,
j
Считая, что узлы укрупненной сетки делят промежуток  a, b , получаем
приближенное соотношение
b

f ( x) dx  CM ,
a
откуда C находим в виде
b
C   f ( x) dx / M .
(14)
a
Учитывая, что  x j  — укрупнение исходной сетки, имеем соответствия
j  i, j  1  i  s , где s  1
— число выброшенных узлов исходной равномер-
ной сетки xi  ih ; из (13) получаем
( xi s  xi ) f ( xi )  C,
16
а отсюда имеем
hs f ( xi )  C.
Подставляя формулу (14), получаем, что вблизи точки x  xi число s  1 выбрасываемых узлов нужно брать
b

s  1    f ( x) dx /  Mh f ( x)   1.
a

(15)
Квадратные скобки обозначают целую часть. Чтобы полученное число получилось неотрицательным, стоит брать h столь малым и M таким большим,
чтобы выполнялось неравенство
b
h

a
f ( x) dx
M max f ( x)
.
(16)
17
3 Реализация
3.1 Выбор средств разработки
Перед тем, как приступать к разработке, следовало определиться, что же
должно быть получено на выходе. Понятно, что на выходе мы должны получить библиотеку, которая должна предоставлять вызывающей программе
удобный интерфейс и инкапсулировать в себе детали, несущественные для
пользователя. В то же время какие-то параметры должны подаваться на вход.
Желательным, а иногда и необходимым, условием для любой программы
является кроссплатформенность, способность функционировать в разных
операционных системах или на разных аппаратных платформах. В нашем
случае это требование не обязательно, но желательно.
В качестве платформы для работы разрабатываемой программы была выбрана Microsoft Windows, как абсолютно самая распространённая операционная система для клиентских компьютеров (по данным статистики с сайтов
www.w3counter.com и marketshare.hitslink.com).
Далее встал вопрос о выборе средств разработки. Очевидно, что создание
неэлементарной программы требует использования алгоритмического языка
высокого уровня и удобных средств разработки/отладки. При выборе ключевой фактор – обеспечение высокой производительности. Для ОС Microsoft
Windows существует несколько вариантов.
В настоящее время в мире Windows довольно популярна платформа .NET
Framework, активно продвигаемая Microsoft. По сути это виртуальная машина, значительно облегчающая труд разработчика. Во-первых, можно выбрать
среди нескольких языков программирования, например, Visual Basic, C#,
функциональный F# и другие. Во-вторых, среда берёт на себя управление
памятью. Программист теперь не должен сам освобождать её в определённый момент времени. За это отвечает Garbage Collector. В-третьих, програм18
мисту доступна огромная библиотека классов FCL (Framework Class Library).
Также использование .NET Framework полностью избавляет от некоторых
распространённых ошибок. Например, невозможно случайно перейти по некорректному указателю или сделать небезопасное приведение типов.
Еще одной виртуальной машиной является JAVA. Она прямой конкурент
.NET Framework и её достоинства во многом такие же. Важным минусом является тот факт, что на любую версию Windows её нужно дополнительно
устанавливать, в то время как .NET Framework уже входит в состав последних версий Windows. По этой причине JAVA была отброшена из рассмотрения.
В качестве еще одного варианта был рассмотрен язык C++. Он является
одним из самых популярных АЯВУ в истории. Он поддерживает и функциональный и объектно-ориентированный стили программирования (.NET полностью объектно-ориентированный). Программа на C++ компилируется
напрямую в машинные коды. Поэтому используя расширения языка возможно использование более низкоуровневых конструкций (например, ключевое
слово asm).
Благодаря высокой производительности был выбран именно C++. На самом деле все перечисленные достоинства платформы .NET в контексте
нашей задачи особой роли не играют: выбор управляемого языка практически очевиден, библиотека классов практически не использовалась бы. Единственное, что могло бы облегчить разработку – сборка мусора. Однако это
преимущество не слишком важно, на фоне проигрыша в производительности, который был бы неизбежен. Например, известно, что сборка мусора
несовместима с системами реального времени, потому что Garbage Collector
может запуститься в любой момент, на неопределённое время остановив программу.
19
Ещё был вариант спуститься ещё ниже и использовать чистый C. Но в
этом случае работа с памятью была бы действительно неудобна: в C нет
классов, а значит, нет возможности освободить память в деструкторе. К тому
же выигрыш в производительности весьма сомнителен. При написании программы использовалось только то, что действительно нужно, из-за этого правила в программе не было сделано ни одного вызова виртуальной функции
или перегруженного оператора.
Выбрав C++, мы получили несколько дополнительных возможностей. Вопервых, кроссплатформенность. В программе использовались лишь встроенные типы (и их псевдонимы), а также функции и классы из STL (Standard
Template Library). Эта встроенная библиотека шаблонов бедна по сравнению
с FCL, однако в ней есть всё, что требовалось для решения поставленной задачи. Во-вторых, есть возможность применять различные компиляторы для
повышения эффективности. Например, существует Intel C++ Compiler для
нескольких операционных систем. Он генерирует код, оптимизированный
для семейства процессоров. Причём есть возможность оптимизации, основанная на анализе типичного хода выполнения программы.
В качестве среды для разработки была выбрана Visual Studio 2008. Она
включает в себя оптимизирующий компилятор, отладчик, поддержку нескольких проектов в одном solution, разные конфигурации (Release, Debug;
x32, x64).
Далее требовалось выбрать тип библиотеки для компиляции. С точки
зрения написания кода это не очень существенно. Но важно для вызывающего кода.
Первый вариант - полностью статическая библиотека. В этом случае генерируется lib файл. Использующее его приложение должно линковаться
вместе с этим lib файлом. Таким образом, получается, что код алгоритма попадает внутрь exe файла.
20
Второй вариант – использование DLL (dynamic-link library). Её можно загрузить вручную в процессе исполнения программы (а потом также выгрузить). Либо можно сделать так, чтобы DLL сама загружалась на старте процесса. В этом случае нужно компилироваться вместе с lib файлом, в котором
на этот раз будет лишь описание доступных функций, но не реализация. Очевидно, что второй подход гораздо лучше иллюстрирует понятие модульности. Был выбран именно он.
3.2 Интерфейс
Теперь следует определиться, какие функции должна реализовывать библиотека. Очевидно, что алгоритм должен уметь построить укрупнённую сетку и выдать по ней основной поток. В терминологии параграфа (2.4) это коэффициенты ai . Естественно сами коэффициенты для пользователя бесполезны. Поэтому необходимо по узлам сетки и набору ai  восстанавливать
функцию. Эта функциональность также реализована в DLL.
Гораздо интереснее вопрос об использовании вэйвлетного потока коэффициентов. Во введении было подробно описано его назначение. В нашем
случае идеально было бы предложить саму функцию. Но функция в компьютере должна как-то представляться. И если изначально неизвестно, что это
сложная композиция нескольких функций, то не ясно, как её представить.
Получается, что машинное представление функции – это уже некоторая аппроксимация. Естественно она должна быть максимально точной. Поэтому в
теории вэйвлетов изначально рассматривается проекция на S 1 ( X ) , где X —
мелкая сетка. Далее сетка укрупняется, получается X . Проекция на S 1 ( X ) кодируется основным потоком. Вэвлетный поток позволяет вернуться к проекции на пространство S 1 ( X ) без лишнего пересчёта (коэффициенты, полученные при проекции на S 1 ( X ) по-прежнему используются). Примечательно, что
сделать это в случае с гладким потоком можно гораздо проще, чем в примере
с изображением.
21
Если применить к картинке один раз вэйвлетное разложение, то в основном потоке окажется полу сумма соседних пикселей. После второго преобразования (на этот раз к основному потоку) получится среднее арифметическое
четырёх соседних пикселей. Как видно, это совсем другие числа. А теперь
обратимся к определению оператора проектирования в параграфе (2.4). Исходный поток для более мелкой сетки отличается наличием производных и
значений функции в новых узлах. Как видим, исходный поток для сетки X
включает в себя основной поток для сетки X . Таким образом, исходную точность можно получить, досчитав коэффициенты для узлов из множества
X\X
.
Передача вэйвлетного потока может сэкономить объём передаваемых
данных только если использовать информацию, что числа bi маленькие по
модулю. В этом случае их можно было бы передавать в сжатом виде так, что
каждое число занимало бы меньше традиционных 8 байт типа double. Но такие методы к исходной задаче отношения не имеют и в данной работе не рассматриваются.
Исходя из всего вышеупомянутого, было принято решение не реализовывать расчёт вэйвлетного потока, а ограничиться основным. Таким образом,
библиотека экспортирует две функции: EncodeFunction для кодирования
функции и ApproximateValueAt для подсчёта аппроксимации. Их описание находится в файле interface.h, который используется и библиотекой и
вызывающим кодом.
Уже очевидно, что EncodeFunction должна принимать в себя много
входных для алгоритма параметров. Поскольку они логически связаны, то
для удобства все они были упакованы в структуру FUNC_INFO.
typedef double (__cdecl *FUNC)(double x, uintptr_t user_value);
struct FUNC_INFO
{
FUNC userFunc;
FUNC userFuncDer;//производная
22
double left, right;
unsigned InitNodeCount;
double PartOfNodesToPreserve;
};
Тип FUNC определяет подаваемые алгоритму на вход функцию и её производную. С точки зрения C++ это указатель на функцию. __cdecl – это соглашение о вызове. Смысл параметра x и возвращаемого значения очевиден.
Интересен параметр user_value. Он позволяет пользователю передать какое-то состояние внутрь своей же функции. Нужно это из следующих соображений. Ранее упоминалось, что функция может быть представлена таблицей, полученной извне. Но откуда же функция с фиксированной сигнатурой
возьмёт эту таблицу. Только из глобальной переменной, что абсолютно неправильно и неудобно. Зато с наличием дополнительного параметра user_value указатель на таблицу можно передать в нём. Тип uintptr_t
является базовым. Он является беззнаковым целым и вне зависимости от
платформы может хранить в себе любой указатель.
Назначение полей userFunc и userFuncDer понятно. Это функция
и её производная. left и right определяют отрезок, на котором происходит действие. Напомним, что функция должна быть гладкости C 1 на нём.
InitNodeCount – это количество узлов в мелкой равномерной сетке, стественно оно должно быть минимум 2. Первый и последний узлы всегда совпадают с left и right соответственно. Библиотека гарантирует, что значение функции и производной будут запрашиваться только в этих точках (но
необязательно во всех). Таким образом, если изначально функция пользователя задана равномерной сеткой, то имеет смысл выставить правильное значение узлов. PartOfNodesToPreserve обозначает примерную долю узлов, сохраняемых при укрупнении сетки. Это должно быть число из интервала  0,1 . В параграфе (2.6) был дан алгоритм выбора сетки. В нём фигурировало
M
–
число
сохраняемых
узлов.
Можно
считать,
что
M
InitNodeCount * PartOfNodesToPreserve. Равенство, конечно,
23
приблизительное. В реальности алгоритм выберет несколько больше узлов,
чем задано. Этот параметр даёт пользователю возможность повлиять на размер выходных данных.
Этим список запрашиваемых у пользователя параметров не заканчивается. Библиотека нуждается в функции, выделяющей память. Почему, ответим
далее. Сигнатура должна быть следующая:
typedef uintptr_t (__cdecl *ALLOC_FUNC)(size_t size,
uintptr_t user_value);
Функция принимает количество байт, которое нужно выделить и возвращает указатель. Назначение дополнительного параметра такое же, как и в
предыдущем случае.
Но почему бы библиотеке самой не выделять необходимую память для
коэффициентов и узлов? Для ответа на этот вопрос необходимо понять, откуда вообще программа динамически берёт память. В C++ принято выделять
память оператором new. Этот оператор – функция из CRT (C runtime library).
Она выделяет память из своей кучи, помня о выделенном блоке до его освобождения. Из этого следует, что память (с помощью оператора delete) может
освободить только тот же самый экземпляр CRT, что её выделял.
Слинковаться с CRT можно статически и динамически. Это настройка
компилятора. В первом случае вся реализация будет внутри создаваемого
модуля, во втором – во внешней DLL. Таким образом, пользовательский код
может освободить память, выделенную в библиотеке, если они оба слинкованы динамически с одним и тем же CRT. Вероятность этого события нулевая,
даже если вызывающий код тоже написан на C++ в той же версии Visual
Studio. Дело в том, что конечный продукт никогда не компилируют с динамически подключаемой CRT DLL. Потому что без неё он не запустится. А
откуда у конечного пользователя экземпляр CRT DLL? Ведь Visual Studio
той же версии, что и у разработчика, у него скорее всего нет. Поэтому разра24
батываемая библиотека тоже слинкована с CRT статически, и вызывающий
код не сможет очистить память, выделенную внутри неё. Игнорирование этого факта грозит серьёзными утечками памяти.
Внутри своей функции выделения памяти можно вполне использовать
привычный оператор new:
uintptr_t MyAllocator(size_t size, uintptr_t user_value)
{
return (uintptr_t) new char[size];
}
Результат работы функции также возвращается в виде структуры:
struct OUT_SEQUENCE
{
double* pNodes;
double* pStream;
unsigned nodeCount, coeffCount;
};
Два указателя представляют собой массивы узлов укрупнённой сетки и
коэффициенты соответственно. Оставшиеся переменные содержат в себе количество элементов в этих массивах. Никакой полезной для пользователя
информации эта структура не несёт. Единственное что требуется от пользователя – это корректно очистить память, на которую ссылаются указатели,
когда структура больше не нужна. Самый простой способ сделать это – унаследовать класс от OUT_SEQUENCE, который в деструкторе бы освобождал
память. Окончательный вид функции EncodeFunction следующий
bool __cdecl EncodeFunction(const FUNC_INFO& func_info,
uintptr_t userParam, ALLOC_FUNC allocFunc, uintptr_t userAllocParam,
OUT_SEQUENCE& out_data);
Функция возвращает false в случае ошибки. В случае успеха поля переменной out_data заполняются корректными данными.
Сигнатура функции, вычисляющей аппроксимацию, следующая
std::pair<double, bool> __cdecl
OUT_SEQUENCE& encodedStream);
ApproximateValueAt(double
dArg,
const
25
Первый параметр – это аргумент функции, второй – ровно та структура,
которую ранее заполнила функция EncodeFunction. Возвращаемое значение – пара из двух чисел. Первое поле типа double содержит как раз результат вычисления. Вторая переменная содержит true в том и только в том
случае, если результат вычисления корректен. Ошибка возможна в случае,
когда структура out_data заполнена неверно, или аргумент находится вне
отрезка, для которого строилось приближение.
3.3 Краткое описание реализации
В этом параграфе будут описаны все шаги алгоритма и некоторые детали
реализации. Начнём с функции EncodeFunction, которая выполняет кодирование. Первым шагом является проверка корректности входных данных.
Дальше начинает работу сам алгоритм.
Алгоритм должен выполнить разряжение сетки, как показано в параграфе
(2.6). Исходя из формулы (15), видно, что необходимо знать интеграл от модуля производной и нужно будет посчитать вдобавок производные в сохранённых узлах. Также эти производные потребуются дальше. Очевидна необходимость кэширования в момент первого вычисления, т.е. в момент вычисления интеграла.
Можно заметить, что интеграл от модуля производной — это почти интеграл от производной. А последний элементарно считается по формуле Ньютона-Лейбница. Таким образом, необходимо вычислить участки знакопостоянства производной. На каждом таком отрезке искомый интеграл равен модулю разности значений функции на концах. Чтобы получить окончательный
результат нужно сложить все промежуточные интегралы по отрезкам.
Такой подход оставляет место для оптимизации. Например, можно искать
участки знакопостоянства на гораздо более крупной сетке. В случае применения, например, формулы трапеции это привело бы к большей ошибке. Но в
описанном алгоритме результат будет примерно такой же, если вблизи своих
26
корней производная близка к 0. Другой вариант - предоставить пользователю
более удобный интерфейс в случае, если он что-то знает о своих данных.
Например, он может знать, что входная функция возрастает, т.е. производная
всегда неотрицательна. Тогда расчёт интеграла делался бы одной операцией
вычитания.
Следующий шаг — вычисление укрупненной сетки. Первый сохраняемый
узел – это левая граница отрезка. Далее количество выкидываемых узлов
определяется формулой (15). Заметим, что интеграл уже вычислен, а производные в узлах кэшированы. Из этого следует, что используются только локальные данные.
По ходу реализации возникло несколько проблем. Во-первых, пользователь не может обеспечить выполнение неравенства (16), потому что не может
знать ни интеграла, ни максимума производных. Без этого неравенства количество выбрасываемых узлов может получиться отрицательным. В этом случае переходим к следующему узлу, ничего не выбрасывая.
Вторая проблема труднее. В параграфе (2.6) отмечалась основная идея
выбора сетки. Приращение функции между соседними узлами должно равняться примерно C . Проблема в том, что производная в точке характеризует
скорость роста функции только вблизи этой точки. Т.е. следующий узел должен оказаться недалеко от предыдущего. В реальности же значение производной может оказаться близким к 0. И поделив на него согласно формуле
(15), можно получить, что выбросить надо чуть ли не половину всех имеющихся узлов. Сетка, которая получится в результате, не позволила бы эффективно приближать функцию на всём отрезке. Поэтому было выставлено дополнительное
ограничение,
2/PartOfNodesToPreserve
что
подряд
выбрасываются
не
более
узлов. Аргументация следующая. Если
нужно сохранить долю узлов равную PartOfNodesToPreserve, то в
среднем будет выбираться один узел из 1/PartOfNodesToPreserve. Мы
27
накладываем ограничение, что как минимум один узел будет выбран из в два
раза большего числа подряд идущих узлов.
После выбора сетки становится известно, сколько коэффициентов будет
вычислено. Соответственно выделяются буферы для массивов узлов и коэффициентов нужной длины. Полученные указатели сразу присваиваются соответствующим переменным структуры OUT_SEQUENCE. Это значит, что если
пользовательская функция выделения памяти сгенерирует исключение, то
библиотека не потеряет выделенный буфер и у пользователя будет возможность его очистить. В массив узлов записываются элементы крупной сетки в
порядке возрастания. Коэффициенты записываются в порядке соответствия
узлам.
Перейдём к рассмотрению функции ApproximateValueAt. Она должна вычислить значение аппроксимации в точке. Формула дана в параграфе
(2.4) в определении оператора проектирования P . Необходимо посчитать
сумму ряда, где лишь 3 слагаемых могут быть ненулевыми. Чтобы определить их индексы, необходимо найти пару узлов, между которыми лежит аргумент. Это выполняется бинарным поиском за логарифмическое время (ведь
узлы были упорядочены). Далее нужно найти значение каждого из 3 сплайнов в точке.
3.4 Возможности распараллеливания
Говоря о современной программе, выполняющей значительную вычислительную работу, нельзя не сказать о возможности для распараллеливания.
Ведь на сегодняшний день все современные процессоры многоядерные. А
значит, для обеспечения масштабируемости приложения нужно эффективно
распределять работу между несколькими потоками.
Правда всегда необходимо помнить, что многопоточность – это всегда
создание/уничтожение потоков и синхронизация. Все перечисленные операции стоят дорого и могут запросто превзойти по времени работу самого ал28
горитма. Таким образом, распараллеливать стоит только по-настоящему долгие и не связанные между собой операции.
Рассмотрим для начала функцию ApproximateValueAt. Она за логарифмическое время ищет относительное положение аргумента и после выдаёт ответ, сделав несколько арифметических операций. Распараллеливать на
уровне библиотеки здесь просто нечего. Но вот вызывающий код скорее всего будет считать аппроксимацию для большого количества точек. И параллелизм на уровне вызывающего кода необходим. Библиотека позволяет это
сделать. Никаких состояний между вызовами не хранится. В программе вообще нет глобальных переменных.
Теперь обратимся к потенциально долгой функции EncodeFunction.
Она вполне может быть распараллелена. Здесь стоит сделать два замечания.
Во-первых, далее везде предполагается, что пользовательская функция, подаваемая на вход, может безопасно вычисляться в разных потоках. Если нет, то
и говорить не о чем. Во-вторых, чтобы получить видимый выигрыш в производительности, входные данные должны задавать очень большое число узлов
или пользовательская функция в точке должна вычисляться относительно
долго.
Сначала выделим самый неперспективный подход к распараллеливанию.
Это ручное управление потоками. Причин тому несколько. В C++ нет встроенных средств для многопоточного программирования, а значит, библиотека
сразу станет зависима от ОС или сторонних библиотек. Далее, очень трудно
будет добиться масштабируемости, т.к. число логических процессоров в системе заранее не известно. Вдобавок к распараллеливанию собственного кода
придётся создать свой Thread Pool, что другая большая работа. И наконец,
заметим, что управление (а конкретно создание и особенно уничтожение) потоками внутри DLL задача в разы более сложная, чем в случае исполняемого
EXE модуля. За подробностями можно обратиться к [6].
29
Поэтому положиться нужно на автоматические средства распараллеливания. Большую часть процессорного времени EncodeFunction проводит
внутри конструкций типа
for (int i = 0; i < BigInt; ++i)
{
//Do something
}
где итераций много, но все они независимы. Поэтому ключевой вопрос «как
распараллелить приведённый цикл». Выделим два способа.
OpenMP (Open Multi-Processing) – программный интерфейс для многопоточного программирования с общей памятью для языков C, C++, Fortran. Он
доступен для нескольких платформ, в том числе для Microsoft Windows.
OpenMP разработан совместно ведущими производителями аппаратного и
программного обеспечения. Программист получает простой, но гибкий интерфейс для создания параллельных приложений как для персональных компьютеров, так и для высокопроизводительных майнфрэймов. С помощью
OpenMP предыдущий фрагмент переписывается следующим образом
#pragma omp parallel for schedule(static)
for (int i = 0; i < BigInt; ++i)
{
//Do something
}
Количество потоков, способ распределения итераций между ними – это
всё настраивается. По умолчанию потоков будет ровно столько же, сколько
логических процессоров. Компилятор Visual Studio поддерживает OpenMP,
поэтому его интеграция совершенно безболезненна.
Второй способ имеет непосредственное отношение к Visual Studio 2010.
Она включает в себя новый компонент Concurrency Runtime. Это можно
назвать большой библиотекой для создания многопоточных приложений. Её
цель скорее не распараллелить существующие конструкции, а помочь в создании архитектуры исконно многопоточного приложения. Но такая простая
задача, как распараллелить цикл, естественно тоже решается
30
Concurrent:: parallel_for(0, BigInt, [] (int i)
{
//Do something
});
В приведённом примере использована лямбда-функция – нововведение C++.
Этот код также выполняется параллельно. Возникает вопрос, что лучше?
На сайте MSDN есть несколько параграфов, посвящённых сравнению
OpenMP и Concurrency Runtime. Там отмечается, что OpenMP очень хорош в
системах, архитектура которых известна на момент компиляции. А Concurrency Runtime дополняет возможности OpenMP, предоставляя Parallel Patterns
Library (PPL) и Asynchronous Agents Library. Показанная функция
parallel_for является представителем PPL. Так же отмечается, что Concurrency Runtime предоставляет динамический планировщик, который балансирует нагрузку между процессорами во время исполнения. Он действительно есть, но ни слова не сказано о качестве, что не случайно.
В Visual Studio 2010 был рассмотрен следующий пример. Пройти по массиву из 107 чисел и возвести в квадрат. Это очень напоминает нашу ситуацию
– итераций много, но все они очень просты. Однопоточная версия тратила на
это несколько миллисекунд, а многопоточная в три раза дольше. Причём
планировщик всё равно решил всё посчитать в одном потоке! При увеличении количества чисел в 10 раз динамка не менялась. А вот OpenMP выиграл
у однопоточной версии. В другом примере, где итерации были более длительными, многопоточность действительно заработала на полную катушку.
По ходу выполнения было создано около 12 потоков (а выполнялось это не
на Blue Gene) и к концу цикла многие были уничтожены.
В общем, вопросы к Concurrency Runtime есть. Но вернёмся к описанию
алгоритма распараллеливания EncodeFunction, считая, что способ распараллеливания цикла и параллельного запуска задач выбран.
Обязательно необходимо распараллелить цикл, кэширующий значения
производных. Ранее отмечалось, что это самый длинный цикл в программе,
31
т.к. он обходит мелкую сетку. Алгоритм вычисления интеграла вычислительно простой и работает с локальными данными. Распараллелить его не
очень просто (хотя возможно), но ненужно. Это примерно как возведение в
квадрат элементов массива. Тоже самое можно сказать про укрупнение сетки. Заполнение коэффициентов и узлов можно запустить параллельно относительно друг друга (хотя заполнение коэффициентов выполнится намного
быстрее). В функции, заполняющей коэффициенты, в цикле вычисляются
значения и производные (которые уже кэшированы). Поэтому этот цикл также возможно распараллелить.
Насколько всё это необходимо определяет предметная область. Может
оказаться, что и описанного параллелизма будет недостаточно. Тогда возможно изменение логики функции. Можно уменьшить плотность сетки при
вычислении интеграла. Также можно заставить функцию вычисления сетки
отдавать узлы на лету, чтобы коэффициенты начинали параллельно генерироваться ещё до конца вычисления всей сетки. При этом неизбежно возникнет синхронизация между потоками, которой в описанном выше способе нет.
Выигрыш возможен в этом случае только для очень большого числа сохраняемых узлов.
32
4 Заключение
В данной работе был подробно рассмотрен и реализован один из алгоритмов вэйвлетного сжатия потока.
Ключевой момент – это выделение основного потока, который наилучшим образом кодирует исходную функцию. Если точность получилась недостаточной, то существует возможность повысить её, не пересчитывая уже
имеющиеся коэффициенты. Достигается это применением произвольной, необязательно равномерной сетки. Таким образом, появляется возможность
эффективно аппроксимировать даже цифровые потоки с резко меняющимися
характеристиками (со сменой плавного поведения на скачкообразное и
наоборот) за счёт использования более мелкой сетки на скачкообразных
участках.
Плюсом представленного алгоритма является быстрое вычисление аппроксимации. Как было показано ранее, единственная вычислительная трудность – это поиск соседних узлов для аргумента. Это может быть выполнено
за логарифмическое от количества узлов время. Последующие вычисления
практически не требуют процессорного времени.
Отдельное внимание в работе было уделено повышению производительности с помощью многопоточности. Было показано, что эффективное (равномерное) распараллеливание ключевых участков программы возможно при
любом количестве доступных логических процессоров. При этом нет необходимости в синхронизации между потоками. Это означает, что вычислительные ресурсы не будут простаивать без дела.
33
1.
2.
3.
4.
5.
6.
5 Список литературы
Ю. К. Демьянович, В. А. Ходаковский «Введение в теорию вэйвлетов»,
СПб, 2007.
Ю. К. Демьянович, О. М. Косогоров «Сплайн-вэйвлетные разложения
на открытом и замкнутом интервалах» // Проблемы математического
анализа, выпуск 43, 69-86, 2009.
Ю. К. Демьянович «Всплески & минимальные сплайны», СПб, 2003.
Microsoft Concurrency Runtime http://msdn.microsoft.com/
OpenMP спецификация http://openmp.org/
Microsoft «Best Practices for Creating DLLs»
34
Download