Нейросеть на Go с нуля · Глава 2 из 10

Глава 2. Матрицы на срезах: фундамент вычислений

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Введение

В первой главе один нейрон считался циклом по входам. Слой из 128 нейронов, который смотрит на 784 входа и обрабатывает батч из 64 примеров, — это 6,4 миллиона умножений на один прямой проход. Писать это вложенными циклами прямо в коде слоя нельзя: получится нечитаемо и неотлаживаемо. Нужен один аккуратный примитив — матрица, — и все слои будут выражены через несколько операций над ним.

Матрица в нашем коде — это не абстрактная математика, а структура данных, от устройства которой напрямую зависит скорость обучения. В этой главе мы разберём, почему [][]float64 — плохая идея, напишем пакет internal/matrix с полным набором операций, покроем его табличными тестами и измерим бенчмарком, как порядок вложенных циклов меняет время выполнения в разы, не меняя ни одной формулы.

Хранение: один плоский срез

Первое, что приходит в голову для матрицы, — срез срезов:

// Так делать НЕ будем.
type Matrix [][]float64

Выглядит естественно, читается как m[i][j], но у этого представления три беды:

  • Память разбросана. Каждая строка — отдельная аллокация в произвольном месте кучи. Процессор читает память кешлиниями по 64 байта и умеет предсказывать последовательный доступ; прыжки по несвязанным блокам сводят это преимущество к нулю. Разница на больших матрицах — в разы.
  • Нет гарантии прямоугольности. Ничто не мешает строке номер 3 иметь другую длину. Такую ошибку компилятор не поймает, а падение случится глубоко внутри умножения.
  • Дороже аллокации. Матрица 784×128 — это 785 аллокаций вместо одной и лишняя работа сборщику мусора на каждом шаге обучения.

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

package matrix

import "fmt"

// Matrix — плотная матрица вещественных чисел, хранимая построчно
// (row-major): элемент (r, c) лежит в Data[r*Cols+c].
type Matrix struct {
    Rows int
    Cols int
    Data []float64
}

// New создаёт матрицу rows×cols, заполненную нулями.
func New(rows, cols int) *Matrix {
    if rows <= 0 || cols <= 0 {
        panic(fmt.Sprintf("matrix.New: некорректный размер %dx%d", rows, cols))
    }
    return &Matrix{Rows: rows, Cols: cols, Data: make([]float64, rows*cols)}
}

// NewFrom создаёт матрицу из готового среза значений (построчно).
func NewFrom(rows, cols int, values []float64) *Matrix {
    if len(values) != rows*cols {
        panic(fmt.Sprintf("matrix.NewFrom: нужно %d значений, дано %d", rows*cols, len(values)))
    }
    m := New(rows, cols)
    copy(m.Data, values)
    return m
}

// At возвращает элемент (r, c).
func (m *Matrix) At(r, c int) float64 {
    return m.Data[r*m.Cols+c]
}

// Set записывает значение в элемент (r, c).
func (m *Matrix) Set(r, c int, v float64) {
    m.Data[r*m.Cols+c] = v
}

Формула r*Cols+c — вся суть построчного (row-major) хранения. Строка целиком лежит подряд, поэтому проход по строке идеален для кеша, а проход по столбцу — наоборот, прыгает через Cols элементов. Мы будем этим пользоваться сознательно.

Почему не интерфейс

Можно было объявить type Matrix interface { At(r, c int) float64 } и подставлять разные реализации — разреженные, транспонированные-без-копирования и так далее. Не будем: в горячем цикле каждый вызов через интерфейс стоит косвенного перехода и лишает компилятор возможности заинлайнить доступ. Конкретный тип с публичным полем Data — намеренное решение ради скорости и прозрачности. Это ровно тот случай, когда «преждевременная абстракция» стоила бы реальных процентов производительности.

Базовые операции

Начнём с самой важной — матричного умножения. Именно оно съест 90% времени обучения.

// Dot возвращает произведение матриц a×b.
// Размерности: (n×k) × (k×m) = (n×m).
func Dot(a, b *Matrix) *Matrix {
    if a.Cols != b.Rows {
        panic(fmt.Sprintf("matrix.Dot: несовместимые размеры %dx%d и %dx%d",
            a.Rows, a.Cols, b.Rows, b.Cols))
    }
    out := New(a.Rows, b.Cols)
    for i := 0; i < a.Rows; i++ {
        for k := 0; k < a.Cols; k++ {
            aik := a.Data[i*a.Cols+k]
            if aik == 0 {
                continue // мелкая, но бесплатная экономия: после ReLU нулей много
            }
            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]
            }
        }
    }
    return out
}

Обрати внимание на порядок циклов: i, затем k, и только внутри — j. Классическая запись из учебника (i, j, k с накоплением суммы) читается привычнее, но заставляет процессор бегать по столбцу матрицы b, то есть прыгать через b.Cols элементов на каждом шаге. В варианте i-k-j оба внутренних среза читаются последовательно, и кеш работает на нас. Формально это то же самое число умножений; практически на матрицах 512×512 разница достигает двух-трёх раз. Мы измерим её бенчмарком ниже.

Ещё одна деталь: rowB := b.Data[...] и rowOut := ... — это не украшательство. Взяв срез один раз, мы избавляем внутренний цикл от повторного вычисления смещений и даём компилятору убрать часть проверок границ.

Транспонирование

// T возвращает новую транспонированную матрицу.
func (m *Matrix) T() *Matrix {
    out := New(m.Cols, m.Rows)
    for i := 0; i < m.Rows; i++ {
        for j := 0; j < m.Cols; j++ {
            out.Data[j*out.Cols+i] = m.Data[i*m.Cols+j]
        }
    }
    return out
}

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

Поэлементные операции и Apply

// Add возвращает поэлементную сумму матриц одинакового размера.
func Add(a, b *Matrix) *Matrix {
    mustSameShape("Add", a, b)
    out := New(a.Rows, a.Cols)
    for i, v := range a.Data {
        out.Data[i] = v + b.Data[i]
    }
    return out
}

// Sub возвращает поэлементную разность a-b.
func Sub(a, b *Matrix) *Matrix {
    mustSameShape("Sub", a, b)
    out := New(a.Rows, a.Cols)
    for i, v := range a.Data {
        out.Data[i] = v - b.Data[i]
    }
    return out
}

// Hadamard — поэлементное произведение (не путать с Dot!).
func Hadamard(a, b *Matrix) *Matrix {
    mustSameShape("Hadamard", a, b)
    out := New(a.Rows, a.Cols)
    for i, v := range a.Data {
        out.Data[i] = v * b.Data[i]
    }
    return out
}

// Scale умножает все элементы на скаляр.
func (m *Matrix) Scale(s float64) *Matrix {
    out := New(m.Rows, m.Cols)
    for i, v := range m.Data {
        out.Data[i] = v * s
    }
    return out
}

// Apply применяет функцию к каждому элементу — так реализуются активации.
func (m *Matrix) Apply(f func(float64) float64) *Matrix {
    out := New(m.Rows, m.Cols)
    for i, v := range m.Data {
        out.Data[i] = f(v)
    }
    return out
}

func mustSameShape(op string, a, b *Matrix) {
    if a.Rows != b.Rows || a.Cols != b.Cols {
        panic(fmt.Sprintf("matrix.%s: размеры не совпадают: %dx%d и %dx%d",
            op, a.Rows, a.Cols, b.Rows, b.Cols))
    }
}

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

Broadcast смещения

Слой считает Y = X·W + b, где X — батч (строка на пример), а b — один вектор смещений на весь слой. Складывать матрицу 64×128 с матрицей 1×128 напрямую нельзя, поэтому вектор «размножается» по строкам:

// AddRowVector прибавляет вектор-строку (1×Cols) к каждой строке матрицы.
func AddRowVector(m, row *Matrix) *Matrix {
    if row.Rows != 1 || row.Cols != m.Cols {
        panic(fmt.Sprintf("matrix.AddRowVector: ожидалась строка 1x%d, дано %dx%d",
            m.Cols, row.Rows, row.Cols))
    }
    out := New(m.Rows, m.Cols)
    for i := 0; i < m.Rows; i++ {
        for j := 0; j < m.Cols; j++ {
            out.Data[i*m.Cols+j] = m.Data[i*m.Cols+j] + row.Data[j]
        }
    }
    return out
}

// SumRows складывает все строки матрицы в одну (1×Cols).
// Нужна в обратном проходе: градиент смещения — сумма градиентов по батчу.
func SumRows(m *Matrix) *Matrix {
    out := New(1, m.Cols)
    for i := 0; i < m.Rows; i++ {
        for j := 0; j < m.Cols; j++ {
            out.Data[j] += m.Data[i*m.Cols+j]
        }
    }
    return out
}

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

Тесты: считаем руками, проверяем машиной

Матрицы — тот случай, когда тесты писать легко и совершенно необходимо: ошибка в Dot проявится не как падение, а как сеть, которая не учится, и ты потратишь день на поиски.

package matrix

import (
    "math"
    "testing"
)

func TestDot(t *testing.T) {
    // [1 2 3]   [ 7  8]   [ 58  64]
    // [4 5 6] × [ 9 10] = [139 154]
    //           [11 12]
    a := NewFrom(2, 3, []float64{1, 2, 3, 4, 5, 6})
    b := NewFrom(3, 2, []float64{7, 8, 9, 10, 11, 12})

    got := Dot(a, b)
    want := []float64{58, 64, 139, 154}

    if got.Rows != 2 || got.Cols != 2 {
        t.Fatalf("размер %dx%d, ожидался 2x2", got.Rows, got.Cols)
    }
    for i, w := range want {
        if math.Abs(got.Data[i]-w) > 1e-9 {
            t.Errorf("элемент %d: получено %v, ожидалось %v", i, got.Data[i], w)
        }
    }
}

func TestDotPanicsOnBadShapes(t *testing.T) {
    defer func() {
        if recover() == nil {
            t.Fatal("ожидалась паника на несовместимых размерах")
        }
    }()
    Dot(New(2, 3), New(2, 3))
}

func TestTransposeRoundTrip(t *testing.T) {
    m := NewFrom(2, 3, []float64{1, 2, 3, 4, 5, 6})
    back := m.T().T()
    for i := range m.Data {
        if m.Data[i] != back.Data[i] {
            t.Fatalf("двойное транспонирование изменило данные в позиции %d", i)
        }
    }
}

Тест на панику через recover выглядит непривычно, но он важен: проверка размерностей — часть контракта пакета, и её случайное удаление должно ломать сборку.

Бенчмарк: цена порядка циклов

Добавим наивную реализацию рядом с нашей и сравним:

// dotNaive — «учебниковый» порядок циклов i-j-k. Используется только в бенчмарке.
func dotNaive(a, b *Matrix) *Matrix {
    out := New(a.Rows, b.Cols)
    for i := 0; i < a.Rows; i++ {
        for j := 0; j < b.Cols; j++ {
            var sum float64
            for k := 0; k < a.Cols; k++ {
                sum += a.At(i, k) * b.At(k, j) // b читается по столбцу — прыжки по памяти
            }
            out.Set(i, j, sum)
        }
    }
    return out
}

func benchMatrices(n int) (*Matrix, *Matrix) {
    a, b := New(n, n), New(n, n)
    for i := range a.Data {
        a.Data[i] = float64(i%7) - 3
        b.Data[i] = float64(i%5) - 2
    }
    return a, b
}

func BenchmarkDot(bch *testing.B) {
    a, b := benchMatrices(256)
    bch.ResetTimer()
    for i := 0; i < bch.N; i++ {
        _ = Dot(a, b)
    }
}

func BenchmarkDotNaive(bch *testing.B) {
    a, b := benchMatrices(256)
    bch.ResetTimer()
    for i := 0; i < bch.N; i++ {
        _ = dotNaive(a, b)
    }
}
go test ./internal/matrix -bench=Dot -benchmem -run=^$

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

Кейс: как это выглядит в настоящем слое

Проверим на реальных размерах будущей сети MNIST: батч 64 примера по 784 признака, скрытый слой 128 нейронов.

x := New(64, 784)   // батч изображений
w := New(784, 128)  // веса слоя
b := New(1, 128)    // смещения

z := AddRowVector(Dot(x, w), b) // 64×128 — «сырые» выходы слоя
h := z.Apply(func(v float64) float64 {
    if v < 0 {
        return 0
    }
    return v
}) // ReLU

Две строки — и весь слой посчитан для всего батча сразу. Ровно это мы обернём в тип Dense в следующей главе. Обрати внимание: одно умножение Dot здесь выполняет 64×784×128 ≈ 6,4 млн умножений, и именно поэтому мы столько внимания уделили его внутреннему циклу.

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

1. Перепутанные размерности

Dot(w, x) вместо Dot(x, w) — самая частая ошибка курса. Хорошая новость: с проверкой в начале Dot программа падает сразу и с внятным сообщением. Именно поэтому проверку нельзя выбрасывать «ради скорости»: она выполняется один раз на миллионы умножений.

2. Изменение матрицы на месте, когда её ещё используют

// Опасно: если Scale менял бы m, вызывающий код внезапно получил бы
// испорченный вход, а ошибка вылезла бы через три слоя.
out := m.Scale(0.5)

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

3. Сравнение float64 через ==

if got.Data[0] == 58.0 { // так тест однажды упадёт на ровном месте

Вещественная арифметика не ассоциативна: изменишь порядок суммирования — получишь другой последний бит. Всегда сравнивай с допуском: math.Abs(got-want) < 1e-9.

4. Аллокация внутри горячего цикла

Вызов New внутри цикла по батчам создаёт мусор на каждой итерации и заставляет сборщик работать в такт обучению. Пока это терпимо, но привычка «выделять один раз перед циклом» окупится в главе 9, когда мы будем выжимать производительность.

5. Проверка At/Set в горячем цикле

Методы At и Set удобны в тестах и в редком коде, но внутри Dot мы работаем со срезами напрямую. Разница видна в бенчмарке — сравни dotNaive, использующий At, с основной реализацией.

Практика

Задание 1. Пакет matrix

Собери internal/matrix/matrix.go с типом Matrix и функциями New, NewFrom, At, Set, Dot, Add, Sub, Hadamard, Scale, Apply, T, AddRowVector, SumRows. Каждая функция с проверкой размерностей и понятным текстом паники: сообщение должно содержать имя операции и обе формы.

Задание 2. Табличные тесты

Напиши тесты на все операции. Значения считай на бумаге на матрицах 2×3 и 3×2 — маленьких, но неквадратных: квадратные матрицы маскируют ошибки в индексации, потому что r*Cols+c и c*Rows+r для них дают одинаковый размер. Отдельно проверь панику на несовместимых формах для Dot, Add и AddRowVector.

Задание 3. Бенчмарк и вывод

Реализуй dotNaive и сравни с Dot на размерах 64, 128, 256, 512. Запиши результаты в таблицу и посмотри, как разрыв растёт с размером матрицы. Объясни себе словами, почему на матрице 64×64 разница почти незаметна, а на 512×512 — кратная.

Задание 4. Строковое представление

Добавь метод func (m *Matrix) String() string, печатающий матрицу с выравниванием и не более 6 строк и 6 столбцов (остальное — многоточие). Печатать матрицу 784×128 целиком в лог — верный способ повесить терминал; ограничение спасёт тебя не раз при отладке.

Задание 5. Функции создания под будущие главы

Добавь Zeros(rows, cols), Ones(rows, cols) и Eye(n) (единичная матрица). Проверь тестом ключевое свойство: Dot(m, Eye(n)) возвращает матрицу, поэлементно равную m. Это отличный интегральный тест на корректность умножения.

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

  • go test ./internal/matrix -v — все тесты зелёные, включая тесты на панику.
  • Dot на прямоугольных матрицах даёт правильные значения, посчитанные вручную.
  • Dot(m, Eye(n)) совпадает с m.
  • Бенчмарк показывает превосходство i-k-j над i-j-k, и разрыв растёт с размером.
  • Ни одна операция не изменяет свои аргументы — проверено тестом (сохрани копию входа и сравни после вызова).
  • Сообщения паник содержат обе формы матриц: по логу видно, что с чем не совпало.

Итог

Матрица — плоский []float64 плюс два числа: так данные лежат в памяти подряд, аллокация одна, а прямоугольность гарантирована конструктором. Умножение написано в порядке i-k-j, чтобы оба внутренних среза читались последовательно, и это даёт кратное ускорение без изменения математики. Проверки размерностей стоят на входе каждой операции и падают громко, потому что молчаливая ошибка формы — самая дорогая в отладке.

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

Содержание серии (10)