Введение
Полносвязная сеть на 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 — не факультатив, а рабочие инструменты.