Введение
В первой главе один нейрон считался циклом по входам. Слой из 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, чтобы оба внутренних среза читались последовательно, и это даёт кратное ускорение без изменения математики. Проверки размерностей стоят на входе каждой операции и падают громко, потому что молчаливая ошибка формы — самая дорогая в отладке.
Фундамент готов. В следующей главе мы соберём на нём слой, добавим активации и разберёмся, почему инициализация весов случайными числами — не мелочь, а условие, без которого сеть не обучится вовсе.