Динамическая память и хранение по столбцам

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

Allocatable-массив — массив, память под который выделяется во время выполнения оператором allocate и освобождается deallocate (или автоматически), что позволяет работать с данными, размер которых заранее неизвестен.

До сих пор размеры массивов задавались на этапе компиляции. Но реальная задача обычно узнаёт размер данных лишь в рантайме: сколько строк в файле, сколько частиц в симуляции, какую сетку выбрал пользователь. Для этого Fortran предлагает динамические массивы с атрибутом allocatable. В современном Fortran (начиная с 90 и особенно 2003) они стали безопасными и удобными — настолько, что почти вытеснили опасные указатели. Этот урок завершает раздел о массивах динамической памятью, хранением по столбцам и тем, как писать кэш-дружелюбный код.

Allocatable: выделение и освобождение

Динамический массив объявляют с атрибутом allocatable и пустыми границами (:). Память выделяют оператором allocate, освобождают — deallocate.

program allocatable_demo
  implicit none
  real, allocatable :: data(:)
  integer :: n, i
  n = 5                          ! размер становится известен в рантайме
  allocate(data(n))             ! выделяем память под n элементов
  do i = 1, n
    data(i) = real(i) * 2.0
  end do
  print *, "Размер:", size(data)
  print *, "Данные:", data
  deallocate(data)              ! освобождаем память
end program allocatable_demo

Вывод:

 Размер:           5
 Данные:   2.00000000  4.00000000  6.00000000  8.00000000  10.0000000

Атрибут allocatable и форма data(:) объявляют массив, ещё не имеющий памяти. allocate(data(n)) выделяет блок под n элементов уже во время выполнения. После использования deallocate возвращает память системе. Хорошая новость: в современном Fortran allocatable-массив, объявленный в процедуре, освобождается автоматически при выходе — об утечках можно почти не думать.

Безопасное выделение и проверка

Выделение может не удаться (например, при нехватке памяти). Промышленный код проверяет это через необязательные параметры stat и errmsg. Также полезна функция allocated, проверяющая, выделен ли массив.

program safe_alloc
  implicit none
  real, allocatable :: big(:)
  integer :: ierr
  allocate(big(1000), stat=ierr)
  if (ierr /= 0) then
    print *, "Ошибка выделения памяти!"
    stop 1
  end if
  print *, "Выделено:", allocated(big), " размер:", size(big)
  big = 0.0
  deallocate(big)
  print *, "После deallocate выделен:", allocated(big)
end program safe_alloc

Вывод:

 Выделено: T  размер:        1000
 После deallocate выделен: F

Параметр stat=ierr получает код результата: 0 — успех, иначе ошибка. Это позволяет корректно реагировать на нехватку памяти вместо аварийного завершения. allocated(big) возвращает .true./.false. — удобно, чтобы не выделить дважды и не освободить невыделенное.

Автоматическое перевыделение при присваивании

Мощная возможность современного Fortran: при присваивании массива allocatable-переменной она сама подстраивает форму. Если размеры не совпадают, массив автоматически перевыделяется. Это делает код выразительным, хотя и требует понимания скрытых затрат.

program auto_realloc
  implicit none
  integer, allocatable :: a(:)
  a = [1, 2, 3]                  ! выделяется под 3 элемента автоматически
  print *, "Размер:", size(a), " данные:", a
  a = [10, 20, 30, 40, 50]       ! перевыделяется под 5 элементов
  print *, "Размер:", size(a), " данные:", a
end program auto_realloc

Вывод:

 Размер:           3  данные:           1           2           3
 Размер:           5  данные:          10          20          30          40          50

При первом присваивании a ещё не выделен — Fortran выделяет под 3 элемента. При втором форма меняется на 5, и массив автоматически перевыделяется. Удобно, но в горячем цикле такое неявное перевыделение может стоить производительности — об этом стоит помнить.

Хранение по столбцам и кэш

Это, возможно, самое практически важное знание о производительности в Fortran. Многомерный массив раскладывается в линейной памяти по столбцам (column-major): первым меняется самый левый индекс. Значит, элементы m(1,1), m(2,1), m(3,1), m(1,2), ... идут подряд. Чтобы обход массива «бил» по соседним адресам и попадал в кэш, самый левый индекс должен меняться во внутреннем цикле.

program column_major
  implicit none
  integer, parameter :: n = 1000
  real, allocatable :: m(:, :)
  integer :: i, j
  real :: total
  allocate(m(n, n))
  m = 1.0
  total = 0.0
  ! ПРАВИЛЬНО: внешний цикл по столбцам j, внутренний по строкам i
  do j = 1, n
    do i = 1, n
      total = total + m(i, j)    ! i меняется быстрее -> идём по памяти подряд
    end do
  end do
  print *, "Сумма:", total
  deallocate(m)
end program column_major

Вывод:

 Сумма:   1000000.00

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

Зачем вообще динамическая память

Массивы фиксированного размера прекрасны, пока размер действительно известен заранее. Но это редкость в реальных задачах. Программа читает матрицу из файла — её размерность станет ясна лишь после открытия файла. Симуляция частиц меняет их число по ходу дела. Пользователь сам выбирает разрешение сетки. Запасаться «с запасом», объявив real :: a(1000000) на всякий случай, — плохая идея сразу по двум причинам: для мелких задач это пустая трата памяти, а для крупных запаса всё равно не хватит. Динамические массивы снимают эту дилемму: программа берёт ровно столько памяти, сколько нужно прямо сейчас, и возвращает её, когда данные больше не нужны. Это и есть смысл атрибута allocatable — отложить решение о размере с этапа компиляции на этап выполнения.

Исторически в Fortran для динамической памяти существовали два механизма: указатели (pointer) и allocatable-массивы. Указатели гибче — они могут ссылаться на чужие данные, образовывать связные структуры, перенаправляться, — но именно эта гибкость делает их опасными: висячие ссылки, утечки, алиасинг, мешающий оптимизатору. Allocatable-массивы намеренно скромнее: переменная либо владеет своим блоком памяти, либо не выделена — третьего не дано. Зато это владение даёт сильные гарантии. Память автоматически освобождается при выходе из области видимости; невозможно случайно «потерять» блок или сослаться на освобождённый. Современный стиль Fortran однозначен: использовать allocatable везде, где достаточно владения, и приберечь указатели для тех немногих случаев, где действительно нужны разделяемые или перенаправляемые ссылки. Это прямая параллель идее «владения» в современных системных языках — Fortran пришёл к ней по-своему и рано.

Цена удобства: перевыделение и копии

Автоматическое перевыделение при присваивании — одна из самых удобных и одновременно коварных возможностей. Удобство очевидно: можно собрать результат любого размера и просто присвоить его allocatable-переменной, не заботясь о ручном allocate. Коварство — в скрытой цене. Каждое такое присваивание, меняющее форму, означает запрос новой памяти у системы, копирование данных и освобождение старого блока. В разовом коде это незаметно, но в горячем цикле, где массив «дорастает» поэлементно, накладные расходы могут затмить полезную работу. Грамотный приём — оценить итоговый размер заранее и выделить память один раз перед циклом, заполняя уже готовый блок. Там, где рост действительно непредсказуем, прибегают к классической стратегии амортизации: выделять с запасом и удваивать ёмкость по мере заполнения, чтобы перевыделений было логарифмически мало.

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

Column-major как контракт совместимости

Хранение по столбцам влияет не только на скорость собственных циклов, но и на стыковку Fortran с внешним миром. Когда фортрановский код вызывает библиотеку на C или обменивается массивами с Python через интерфейс, обе стороны должны одинаково понимать, как двумерные данные разложены в линейной памяти. Поскольку C и NumPy по умолчанию хранят по строкам (row-major), а Fortran — по столбцам, «матрица» с одной стороны выглядит транспонированной с другой, если просто передать сырой указатель. Это не редкая экзотика, а повседневная реальность научных стеков: многие библиотеки явно поддерживают оба порядка именно потому, что Fortran-порядок настолько распространён, что для него есть отдельное название — «Fortran order». NumPy даже хранит флаг порядка у каждого массива и умеет создавать массивы в фортрановском раскладе специально для передачи в фортрановские и BLAS-процедуры.

Практический вывод: column-major — это не внутренняя деталь компилятора, которую можно игнорировать, а часть контракта, по которому Fortran живёт и внутри себя, и на границах с другими языками. Уважение к этому порядку даёт двойную выгоду — кэш-дружелюбные циклы внутри и беспроблемную совместимость снаружи. Пренебрежение им наказывает сразу с двух сторон: медленным кодом и загадочно «перевёрнутыми» данными на стыке с чужими библиотеками. Поэтому привычка располагать левый индекс во внутреннем цикле и держать в голове, что «по столбцам — значит подряд», — одна из самых окупаемых в арсенале фортран-программиста.

Как работает под капотом

Где именно живёт память allocatable-массива и почему column-major так влияет на скорость? allocate запрашивает блок в куче (heap) — динамической области памяти, в отличие от статических массивов фиксированного размера, что могут лежать в стеке. Дескриптор массива хранит указатель на этот блок, форму и шаги; автоматическое освобождение при выходе из процедуры реализовано через скрытый вызов deallocate, вставляемый компилятором, — отсюда защита от утечек. Что до кэша: процессор читает память не байтами, а кэш-линиями по 64 байта. Обращаясь к m(i, j) с быстро меняющимся i, вы читаете соседние ячейки одной линии — каждая загрузка обслуживает несколько обращений. Если же быстро меняется j, каждое обращение прыгает на n элементов вперёд, в новую линию, и кэш постоянно промахивается. На больших матрицах это различие — разы и десятки раз. Column-major — не прихоть, а контракт, который надо уважать ради скорости.

Частые ошибки

  • Использование невыделенного массива. Обращение к allocatable до allocate — ошибка; проверяйте allocated.
  • Двойное выделение/освобождение. allocate уже выделенного или deallocate невыделенного — ошибка рантайма; используйте stat и allocated.
  • Неверный порядок вложенных циклов. Внутренний цикл должен идти по левому индексу (column-major); обратный порядок убивает производительность.
  • Скрытое перевыделение в горячем коде. Автоматическое изменение формы при присваивании удобно, но в цикле дорого; выделяйте заранее.
  • Перенос порядка циклов из C без изменений. C хранит по строкам, Fortran — по столбцам; копипаст порядка циклов даёт медленный код.

Итоги

  • Атрибут allocatable и форма (:) создают динамический массив без памяти; allocate выделяет её в рантайме.
  • deallocate освобождает память, но в современном Fortran allocatable-массивы освобождаются автоматически при выходе из области.
  • Проверяйте выделение через stat= и состояние через allocated().
  • Присваивание массива allocatable-переменной автоматически подстраивает её форму (перевыделение).
  • Fortran хранит многомерные массивы по столбцам; левый индекс меняется в памяти быстрее.
  • Для скорости внутренний цикл должен идти по левому индексу — это попадает в кэш (в C — наоборот).
Проверьте себя
1. Что нужно сделать перед использованием массива, объявленного как real, allocatable :: data(:)?
AНичего, он готов к работе
BВыделить память оператором allocate(data(n))
CОбъявить его через dimension
DИнициализировать нулём
2. В каком порядке Fortran хранит элементы многомерного массива в памяти?
AПо строкам (row-major), как в C
BПо столбцам (column-major): левый индекс меняется быстрее
CВ случайном порядке
DПо диагонали
3. Почему важен порядок вложенных циклов при обходе матрицы в Fortran?
AЭто влияет только на читаемость
BОбход по левому индексу во внутреннем цикле попадает в кэш и работает в разы быстрее
CПорядок не имеет значения
DТолько для allocatable-массивов