Глава 9. Свёртки и конкурентность

85 просмотров
0 лайков
0 в избранном
Войдите, чтобы поставить лайк. Лайков:

Введение

Полносвязная сеть на MNIST упирается примерно в 98%. Причина в том, что мы разворачиваем картинку 28×28 в вектор из 784 чисел и тем самым выбрасываем информацию о том, какие пиксели были соседями. Свёрточные слои эту структуру используют — и именно поэтому они стали основой компьютерного зрения.

Вторая половина главы — то, ради чего многие и выбирают Go: настоящая конкурентность. Обучение прекрасно параллелится, а горутины делают это простым. Заодно разберём, как честно измерять ускорение и почему наивное «запустим горутину на каждый элемент» делает программу медленнее.

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

Три проблемы, каждая существенная.

Потеря структуры. После разворачивания в вектор пиксель (5,5) и пиксель (5,6) — просто соседние числа в массиве, ничем не связанные, а пиксель (6,5) оказывается в 28 позициях от своего вертикального соседа. Информация о двумерности не пропала физически, но сеть о ней ничего не знает и вынуждена выучивать её из данных.

Число параметров. Слой 784→128 — это 100 480 весов на одну маленькую картинку в оттенках серого. Для цветного изображения 224×224 первый слой потребовал бы 19 миллионов параметров.

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

Свёртка решает всё три разом: маленькое ядро скользит по изображению, применяя одни и те же веса в каждой позиции. Признак «вертикальный штрих» выучивается один раз и работает везде.

Свёрточный слой

Ядро (фильтр) — маленькая матрица, обычно 3×3 или 5×5. Мы прикладываем её к участку изображения, поэлементно перемножаем и складываем — получается одно число выходной карты признаков. Затем сдвигаем ядро и повторяем.

Вход 5×5, ядро 3×3, шаг 1, без дополнения → выход 3×3

out[i][j] = сумма по (di, dj) от input[i+di][j+dj] * kernel[di][dj] + bias

Размер выхода считается по формуле, которую стоит запомнить:

out = (in + 2*padding - kernel) / stride + 1

Для входа 28, ядра 3, дополнения 1 и шага 1 получаем ровно 28 — размер сохраняется. Именно поэтому связка «ядро 3×3 с padding 1» встречается повсеместно.

// Conv2D — свёрточный слой.
// Веса: [outChannels][inChannels][kernel*kernel], смещение — на выходной канал.
type Conv2D struct {
    InCh, OutCh int
    K           int // размер ядра (квадратного)
    Stride      int
    Padding     int

    W []float64 // OutCh*InCh*K*K
    B []float64 // OutCh

    dW, dB    []float64
    lastInput []float64
    inH, inW  int
}

// OutSize возвращает размер выходной карты по одной оси.
func (c *Conv2D) OutSize(in int) int {
    return (in+2*c.Padding-c.K)/c.Stride + 1
}

// forwardOne считает свёртку для одного изображения (наивная реализация).
// Порядок циклов: выходной канал → позиция → входной канал → ядро.
func (c *Conv2D) forwardOne(img []float64, h, w int, out []float64) {
    oh, ow := c.OutSize(h), c.OutSize(w)

    for oc := 0; oc < c.OutCh; oc++ {
        for oy := 0; oy < oh; oy++ {
            for ox := 0; ox < ow; ox++ {
                sum := c.B[oc]
                for ic := 0; ic < c.InCh; ic++ {
                    for ky := 0; ky < c.K; ky++ {
                        iy := oy*c.Stride + ky - c.Padding
                        if iy < 0 || iy >= h {
                            continue // зона дополнения — там нули
                        }
                        for kx := 0; kx < c.K; kx++ {
                            ix := ox*c.Stride + kx - c.Padding
                            if ix < 0 || ix >= w {
                                continue
                            }
                            wIdx := ((oc*c.InCh+ic)*c.K+ky)*c.K + kx
                            sum += img[(ic*h+iy)*w+ix] * c.W[wIdx]
                        }
                    }
                }
                out[(oc*oh+oy)*ow+ox] = sum
            }
        }
    }
}

Пять вложенных циклов — да, это выглядит устрашающе, и да, это работает медленно. Зато здесь видно ровно то, что происходит, без единой абстракции. Обрати внимание: дополнение (padding) мы не материализуем в память, а обрабатываем проверкой границ — так не нужно копировать всё изображение в буфер большего размера.

Пулинг

После свёртки размер карты признаков уменьшают пулингом: берут максимум по окну 2×2 и тем самым вчетверо сокращают данные, оставляя самые сильные отклики.

// MaxPool2x2 уменьшает карту вдвое по каждой оси, беря максимум окна.
// Индексы максимумов кешируются: на обратном проходе градиент
// уходит ТОЛЬКО в тот элемент, который стал максимумом.
func MaxPool2x2(in []float64, ch, h, w int) (out []float64, argmax []int) {
    oh, ow := h/2, w/2
    out = make([]float64, ch*oh*ow)
    argmax = make([]int, ch*oh*ow)

    for c := 0; c < ch; c++ {
        for y := 0; y < oh; y++ {
            for x := 0; x < ow; x++ {
                best, bestIdx := math.Inf(-1), -1
                for dy := 0; dy < 2; dy++ {
                    for dx := 0; dx < 2; dx++ {
                        idx := (c*h+y*2+dy)*w + x*2 + dx
                        if in[idx] > best {
                            best, bestIdx = in[idx], idx
                        }
                    }
                }
                out[(c*oh+y)*ow+x] = best
                argmax[(c*oh+y)*ow+x] = bestIdx
            }
        }
    }
    return out, argmax
}

Кеш argmax — не оптимизация, а необходимость: пулинг не имеет параметров, но обязан правильно распределить градиент, а он идёт только в победивший элемент окна. Остальные три получают ноль.

im2col: свёртка через умножение матриц

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

Идея: каждый участок изображения размером с ядро развернуть в столбец большой матрицы. Тогда свёртка становится умножением «матрица ядер × матрица участков».

// im2col разворачивает изображение в матрицу (K*K*InCh) × (oh*ow).
// Данные дублируются (соседние окна перекрываются), зато дальше
// работает быстрое умножение матриц.
func im2col(img []float64, ch, h, w, k, stride, pad int) *matrix.Matrix {
    oh := (h+2*pad-k)/stride + 1
    ow := (w+2*pad-k)/stride + 1
    col := matrix.New(ch*k*k, oh*ow)

    for c := 0; c < ch; c++ {
        for ky := 0; ky < k; ky++ {
            for kx := 0; kx < k; kx++ {
                row := (c*k+ky)*k + kx
                for oy := 0; oy < oh; oy++ {
                    iy := oy*stride + ky - pad
                    for ox := 0; ox < ow; ox++ {
                        ix := ox*stride + kx - pad
                        var v float64
                        if iy >= 0 && iy < h && ix >= 0 && ix < w {
                            v = img[(c*h+iy)*w+ix]
                        }
                        col.Data[row*col.Cols+oy*ow+ox] = v
                    }
                }
            }
        }
    }
    return col
}

Плата за скорость — память: каждый пиксель дублируется столько раз, во скольких окнах он участвует (для ядра 3×3 — до девяти). Это осознанный размен, на который идут все реальные библиотеки. Ускорение на MNIST-размерах обычно трёх-пятикратное — измерь на своей машине.

Конкурентность: параллелим правильно

Теперь то, ради чего мы взяли Go. Обучение параллелится по нескольким осям, и выбирать надо самую крупную из доступных.

Правило: параллелить крупно

Запуск горутины стоит порядка сотен наносекунд. Если тело задачи занимает меньше, накладные расходы съедят всю выгоду и программа станет медленнее однопоточной. Поэтому горутину на элемент матрицы — нет; горутину на диапазон строк — да.

// DotParallel — то же умножение матриц, но строки распределены
// между несколькими горутинами.
func DotParallel(a, b *Matrix, workers int) *Matrix {
    if a.Cols != b.Rows {
        panic("DotParallel: несовместимые размеры")
    }
    out := New(a.Rows, b.Cols)
    if workers <= 1 || a.Rows < workers*8 {
        return Dot(a, b) // мелкие матрицы быстрее считать в один поток
    }

    var wg sync.WaitGroup
    chunk := (a.Rows + workers - 1) / workers

    for start := 0; start < a.Rows; start += chunk {
        end := min(start+chunk, a.Rows)
        wg.Add(1)
        go func(start, end int) {
            defer wg.Done()
            // Каждая горутина пишет в СВОИ строки out — пересечений нет,
            // поэтому мьютекс не нужен и гонки не возникает.
            for i := start; i < end; i++ {
                for k := 0; k < a.Cols; k++ {
                    aik := a.Data[i*a.Cols+k]
                    if aik == 0 {
                        continue
                    }
                    rowB := b.Data[k*b.Cols : (k+1)*b.Cols]
                    rowOut := out.Data[i*out.Cols : (i+1)*out.Cols]
                    for j := range rowB {
                        rowOut[j] += aik * rowB[j]
                    }
                }
            }
        }(start, end)
    }
    wg.Wait()
    return out
}

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

Обрати внимание на порог a.Rows < workers*8: на маленьких матрицах параллелизм вредит, и явная проверка честнее, чем надежда на удачу.

Сколько воркеров

workers := runtime.GOMAXPROCS(0) // 0 — «просто узнать текущее значение»

По умолчанию GOMAXPROCS равен числу доступных ядер, и это разумное значение для задач, упирающихся в процессор. Больше воркеров, чем ядер, для чистой арифметики бессмысленно: они будут вытеснять друг друга. В контейнере с лимитом CPU современные версии Go учитывают лимит, но проверить фактическое значение в логе всё равно полезно.

Параллелизм по батчу и почему он опаснее

Заманчиво обрабатывать примеры батча в разных горутинах. Но вспомни главу 5: слой кеширует lastInput в своём поле. Два одновременных прохода через один и тот же слой затрут кеш друг друга, и обратный проход посчитает мусор — молча, без падения.

Варианта два: либо параллелить внутри операций (как в DotParallel выше), либо давать каждой горутине собственную копию сети и потом складывать градиенты. Первый способ проще и в нашем случае достаточен.

Гонки данных и детектор

Гонка — это когда две горутины обращаются к одной памяти и хотя бы одна пишет. Проявляется недетерминированно: программа работает сто раз и падает на сто первый или тихо считает неправильно.

go test -race ./...
go run -race ./cmd/train

Детектор гонок замедляет программу в 5–10 раз и увеличивает потребление памяти, поэтому в продакшене его не включают. Но прогонять тесты с -race после каждой правки конкурентного кода — обязательная привычка: он находит то, что глазами не видно.

// Классическая гонка: аккумулятор общий для всех горутин.
var total float64
for i := range parts {
    go func(i int) {
        total += compute(parts[i]) // ГОНКА: += это чтение и запись
    }(i)
}

// Как надо: каждая горутина пишет в свою ячейку, сумма считается после.
results := make([]float64, len(parts))
var wg sync.WaitGroup
for i := range parts {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        results[i] = compute(parts[i]) // своя ячейка — гонки нет
    }(i)
}
wg.Wait()
var total float64
for _, v := range results {
    total += v
}

Кстати, о переменной цикла: начиная с Go 1.22 переменная i в for создаётся заново на каждой итерации, поэтому классическая ловушка с захватом одной и той же переменной замыканием больше не срабатывает. Явная передача аргументом всё равно не помешает — код читается однозначно и не зависит от версии языка в go.mod.

sync.Pool: переиспользуем буферы

Каждая операция над матрицами выделяет новую матрицу. На эпохе MNIST это десятки тысяч аллокаций, и сборщик мусора начинает заметно отъедать время. sync.Pool позволяет переиспользовать буферы:

var bufPool = sync.Pool{
    New: func() any {
        return make([]float64, 0, 1024)
    },
}

func withBuffer(n int, fn func(buf []float64)) {
    buf := bufPool.Get().([]float64)
    if cap(buf) < n {
        buf = make([]float64, n)
    }
    buf = buf[:n]
    clear(buf) // ОБЯЗАТЕЛЬНО: в буфере лежат данные прошлого использования

    fn(buf)

    bufPool.Put(buf[:0])
}

Главная опасность пула — забыть очистить буфер. Данные предыдущего использования никуда не делись, и результат вычислений окажется загадочно неправильным. Встроенная функция clear (Go 1.21+) обнуляет срез одной строкой.

Важно: sync.Pool имеет смысл только там, где профилировщик показал реальную проблему с аллокациями. Добавлять его «на всякий случай» — способ усложнить код без выигрыша.

Профилирование

Оптимизировать вслепую бесполезно: интуиция про узкие места ошибается систематически. Go даёт профилировщик прямо из коробки.

# Профиль CPU при обучении
go test ./internal/matrix -bench=Dot -cpuprofile=cpu.out -run=^$
go tool pprof -top cpu.out

# Профиль аллокаций
go test ./internal/matrix -bench=Dot -memprofile=mem.out -run=^$
go tool pprof -top -sample_index=alloc_space mem.out

Для долгого обучения удобнее подключить net/http/pprof и снимать профиль на живой программе:

import _ "net/http/pprof" // регистрирует хендлеры на /debug/pprof/

func main() {
    go func() {
        // Отдельный порт только для отладки, наружу не выставляем.
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // ... обучение ...
}
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30

Открывшийся граф покажет, что почти всё время программа проводит внутри Dot. Это ожидаемо — и это подтверждение, что оптимизировать нужно именно его, а не парсер данных, который отработал один раз на старте.

Кейс: честный замер ускорения

func BenchmarkDotParallel(b *testing.B) {
    a, m := benchMatrices(512)
    for _, workers := range []int{1, 2, 4, 8} {
        b.Run(fmt.Sprintf("workers=%d", workers), func(b *testing.B) {
            b.ReportAllocs()
            for i := 0; i < b.N; i++ {
                _ = DotParallel(a, m, workers)
            }
        })
    }
}
go test ./internal/matrix -bench=DotParallel -benchtime=3s -run=^$

Типичная картина на четырёхъядерной машине: два воркера дают ускорение примерно в 1,8 раза, четыре — в 3,2, восемь — те же 3,2 или чуть хуже. Идеальной линейности не бывает: часть времени уходит на запуск горутин, на синхронизацию в конце и на конкуренцию за пропускную способность памяти.

Правила честного замера: закрой тяжёлые приложения, не сравнивай прогоны на батарее и от сети, запускай с -benchtime=3s и повторяй несколько раз. Разброс в 10% между прогонами — нормально, и делать выводы о «ускорении на 5%» по одному запуску нельзя.

Типичные ошибки

1. Горутина на слишком мелкую задачу

Горутина на каждый элемент матрицы или на каждую строку матрицы 10×10 — программа станет в разы медленнее. Дели работу на куски, сопоставимые с числом ядер.

2. Забыть wg.Wait()

main завершается раньше горутин, результат частично не посчитан. Иногда «работает» — если горутины успели, — и это худший вид ошибки.

3. wg.Add внутри горутины

go func() {
    wg.Add(1) // поздно: Wait может пройти до этой строки
    defer wg.Done()
}()

Add вызывается строго до запуска горутины, в теле цикла.

4. Общее изменяемое состояние без синхронизации

Кеш слоя, общий аккумулятор, разделяемый счётчик — все они дают гонку. Проектируй так, чтобы горутины писали в непересекающиеся области, и синхронизация просто не понадобится.

5. Не очистить буфер из sync.Pool

Старые данные складываются с новыми, результат неверен. Симптом — правильные значения на первом прогоне и мусор на последующих.

6. Ложное разделение (false sharing)

Если две горутины пишут в соседние элементы одного среза, они попадают в одну кеш-линию, и процессорам приходится постоянно синхронизировать её между ядрами. Именно поэтому мы делим работу диапазонами строк, а не чередованием (i % workers == id): второе выглядит красиво и работает заметно медленнее.

7. Оптимизация без профиля

Час, потраченный на ускорение функции, которая занимает 0,3% времени, — час впустую. Сначала профиль, потом оптимизация, потом снова профиль.

Практика

Задание 1. Свёрточный слой

Реализуй Conv2D с полями Stride и Padding и наивным прямым проходом. Напиши тест: вход 5×5 из единиц, ядро 3×3 из единиц, padding 0, stride 1 — выход должен быть 3×3, каждый элемент равен 9. Затем проверь формулу размера для padding 1: выход 5×5, а угловые элементы равны 4 (часть ядра пришлась на дополнение).

Задание 2. Пулинг с обратным проходом

Реализуй MaxPool2x2 с кешем индексов максимумов и обратный проход к нему. Проверь тестом: градиент попадает ровно в один элемент каждого окна 2×2, остальные три получают ноль.

Задание 3. im2col и сравнение

Реализуй im2col и свёртку через умножение матриц. Обязательно убедись, что она даёт тот же результат, что наивная (сравнение с допуском 1e-9), и только потом меряй бенчмарком выигрыш. Порядок именно такой: быстрая, но неправильная свёртка бесполезна.

Задание 4. Параллельное умножение

Реализуй DotParallel и прогони бенчмарк на 1, 2, 4, 8 воркерах для матриц 128, 256, 512, 1024. Составь таблицу ускорения. Найди размер, начиная с которого параллелизм окупается, и подставь его в порог внутри функции.

Задание 5. Гонка своими руками

Напиши намеренно гоночную версию (общий аккумулятор без синхронизации), запусти go test -race и прочитай отчёт детектора целиком: он показывает обе точки доступа со стеками. Затем исправь и убедись, что отчёт исчез.

Задание 6. Профиль обучения

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

Задание 7. Свёрточная сеть на MNIST (по желанию)

Собери сеть Conv(1→8, 3×3) → ReLU → MaxPool → Conv(8→16, 3×3) → ReLU → MaxPool → Dense(16*7*7 → 10) и обучи её. Готовься к тому, что эпоха будет идти в разы дольше полносвязной; зато точность 98,5–99% достижима. Если обратный проход свёртки покажется тяжёлым, ограничься прямым и обучением только последнего слоя — это тоже полезный эксперимент.

Чек-лист самопроверки

  • Формула размера выхода свёртки проверена тестом на нескольких комбинациях stride и padding.
  • Свёртка через im2col совпадает с наивной с точностью 1e-9 и работает быстрее.
  • Пулинг направляет градиент только в максимум окна.
  • go test -race ./... проходит чисто.
  • Бенчмарк показывает ускорение при росте числа воркеров и выход на плато около числа ядер.
  • На маленьких матрицах параллельная версия не медленнее последовательной (сработал порог).
  • CPU-профиль снят, и ты знаешь три самые дорогие функции своего обучения.

Итог

Свёртка применяет одно маленькое ядро во всех позициях изображения: параметров на порядки меньше, структура сохраняется, признаки инвариантны к сдвигу. Размер выхода считается по формуле (in + 2p - k)/s + 1, а пулинг сокращает карту признаков, пропуская градиент только в максимум окна. Приём im2col превращает свёртку в матричное умножение и обменивает память на скорость.

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

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!