[!attention] 이 장의 목표
- 동적 할당의 마스터 —
allocatable속성과allocate·deallocate구문을 사용하여 실행 중에 메모리를 유연하게 확보하고 해제한다.- 자동 재할당 활용 — 현대 Fortran의 강력한 편의 기능인 할당가능 대입(allocatable assignment) 시 발생하는 자동 재할당 메커니즘을 이해하고 안전하게 구사한다.
지금까지 다룬 배열은 모두 컴파일 단계에서 크기가 고정되는 정적 배열(static array)이었다. 예를 들어 real :: v(100)과 같이 선언과 동시에 길이가 고정되므로, 프로그램을 빌드하기 전에 필요한 원소 수를 설계자가 미리 완벽하게 알고 있어야만 한다.
그러나 실제 고성능 수치 해석 연산 환경에서는 다음과 같이 실행해 보기 전에는 데이터의 크기를 쉽게 짐작할 수 없는 상황이 일반적이다. 예를 들면, 관측 데이터 처리 프로그램은 사용자가 프로그램을 실행한 후 $n$개의 관측 표본을 입력해야 하는데, 그 다음 실행 시에는 표본 수 $n$이 어떻게 달라질지 예측할 수 없다.
이 장에서는 실행 단계(runtime)에서 배열의 크기를 자유자재로 결정하고 변경할 수 있는 allocatable 속성을 학습한다. 메모리를 동적으로 할당(allocate)하고 안전하게 돌려주는 해제(deallocate) 기법부터, 단순한 대입 연산만으로 배열 크기를 스스로 맞추는 자동 재할당 기능과 메모리 오버헤드를 최소화하며 배열을 효율적으로 확장하는 최적화 기법까지 다룬다.
[!important] 9.1 allocatable 속성과 allocate · deallocate
정적 배열은 선언과 동시에 메모리 크기가 고정되지만, 동적 배열(dynamic array)은 선언할 때 차원(rank) 정보만 지정하고 실제 크기는 비워둔 채 실행 시점에 메모리를 부여받는다. 구문 구조는 아래와 같이 변수에 allocatable 속성을 붙이고 각 차원을 콜론(:)으로 표시하여 선언한다.
real :: v(100) ! 컴파일 시점에 길이 100으로 고정되는 정적 배열
real, allocatable :: v(:) ! 1차원(Rank 1), 크기는 아직 결정되지 않은 동적 배열
real, allocatable :: m(:,:) ! 2차원(Rank 2), 크기는 아직 결정되지 않은 동적 배열
선언 직후의 allocatable 변수는 아직 실제 메모리 주소를 갖지 못한 미할당(unallocated) 상태이다. 반드시 allocate 문을 통해 실제 메모리 공간을 확보해 주어야만 원소에 접근할 수 있으며, 데이터 처리가 끝나면 deallocate 문으로 메모리를 시스템에 반환해야 한다.
allocate(v(n)) ! 실행 시점의 변수 n만큼의 메모리 길이 확보
! ... 배열 v 사용 및 수치 연산 수행 ...
deallocate(v) ! 확보했던 메모리를 시스템에 완전히 반환
선언 시점에 지정한 콜론의 개수(차원 또는 rank)는 도중에 바꿀 수 없으나, 각 차원의 실제 길이는 실행 중에 자유롭게 결정할 수 있다. 대규모 물리 시뮬레이션에서 필요한 시점에만 대형 배열을 메모리에 올리고 사용 후 즉시 제거함으로써 시스템 자원(resouce)을 극한으로 최적화할 수 있는 메모리 관리의 핵심 도구다.
동적 배열을 안전하게 제어하기 위한 문법은 다음 단계로 정의된다.
선언 단계 (real, allocatable :: v(:))
배열이 동적 할당 대상임을 컴파일러에 알리는 단계이다. 데이터 자료형 뒤에 allocatable 속성을 명시하고, 괄호 안에 차원의 수만큼 콜론을 기술하여 선언한다.
할당 단계 (allocate(v(1:n), stat=status))
실제 물리 메모리를 확보하는 단계이다. 하한선을 생략하고 allocate(v(n))과 같이 기술하면 인덱스는 자동으로 $1$부터 $n$까지 부여된다.
해제 단계 (deallocate(v, stat=status))
확보하여 사용이 끝난 메모리를 운영체제에 반환하는 단계이다. 이 처리를 누락하면 프로그램이 실행되는 동안 메모리가 계속 낭비되는 메모리 누수(memory leak)가 발생한다.
상태 점검 단계 (allocated(v))
배열이 현재 메모리에 남아있는지 확인하는 내장 함수다. 현재 할당된 상태라면 참(.true.)을 반환하고, 미할당 상태라면 거짓(.false.)을 반환한다.
[!note] 메모리 보호 및 예외 처리 안전 규칙 - 이중 할당 및 미할당 해제 금지: 이미 메모리를 부여받은 배열에 또다시
allocate를 호출하거나, 반대로 할당된 적이 없는 배열을deallocate하려고 시도하면 프로그램은 런타임 에러(run-time error)를 내며 즉시 강제 종료(crash)된다. 따라서 안전한 코딩을 위해 아래와 같이allocated함수로 상태를 돋보기 검사하듯 확인하는 습관을 들이자.
Fortran if (.not. allocated(v)) allocate(v(n)) if (allocated(v)) deallocate(v)
- 상태 변수(
stat=)를 통한 예외 제어:allocate나deallocate구문 뒤에 선택적으로stat=정수변수옵션을 결합할 수 있다. 메모리 확보에 성공하면 지정한 정수 변수에0이 저장되고, 시스템 메모리 부족 등의 사유로 할당에 실패하면0이 아닌 에러 코드가 대입된다. 이를 활용하면 프로그램이 비정상 종료되는 것을 막고 "메모리가 부족합니다"와 같은 에러 메시지를 출력하여 안전하게 정상 종료(stop 1)하도록 유도할 수 있다.
사용자가 실행 중에 입력한 정수 $n$을 그대로 받아 그 크기만큼 배열의 메모리를 동적으로 생성하고, 연산이 끝나면 안전하게 반환하는 가장 표준적인 동적 할당 예제이다.
%%writefile dynamic_basics.f90
program dynamic_basics
implicit none
integer :: n, i
real, allocatable :: v(:)
read *, n ! size decided at run time
allocate(v(n))
do i = 1, n
v(i) = real(i)**2
end do
print *, "allocated size:", size(v)
print *, "elements:", v
deallocate(v)
end program dynamic_basics
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall dynamic_basics.f90 -o dynamic_basics
!echo 5 | ./dynamic_basics
실행 결과:
allocated size: 5
elements: 1.00000000 4.00000000 9.00000000 16.0000000 25.0000000
실행 시점의 유연한 크기 결정: 소스 코드 상에서 real, allocatable :: v(:)로 선언할 때만 해도 배열의 크기는 아직 백지 상태이다. 프로그램이 실행된 후 read *, n을 통해 리눅스 환경에서 전송된 5라는 숫자를 읽어 들이는 순간, allocate(v(5))가 작동하며 비로소 $5$개의 실수를 담을 공간이 메모리에 확보된다.
정적 배열과의 차별성: 만약 실행 단계에서 echo 10 |이나 echo 1000 |과 같이 다른 숫자로 바꿔주면, 코드를 전혀 수정하지 않고 재컴파일 조차 거치지 않은 채 각각 길이가 10인 배열과 1000인 배열이 자동 맞춤형으로 생성된다. 이것이 빌드 전에 숫자를 반드시 고정해야 했던 정적 배열 구조와 비교해 볼 때, 동적 배열만의 독보적인 편리함이다. 계산이 끝난 후 deallocate(v)를 통해 시스템에 메모리를 즉시 돌려주는 깔끔한 마무리까지 완벽하게 처리되었다.
stat컴퓨터의 물리 메모리 크기는 한계가 있으므로, 대규모 격자 데이터나 거대한 행렬을 다룰 때 시스템의 가용 용량을 초과하는 너무 큰 배열을 요청하면 메모리 확보가 거부된다. 이때 예외 처리 안전장치 없이 allocate를 수행하면 프로그램은 예기치 않게 종료된다.
하지만 구문 뒤에 stat= 옵션을 결합해 두면, 할당 실패 시 프로그램이 강제 종료되는 대신 0이 아닌 특정한 에러 코드를 변수에 담아 반환하므로 개발자가 안전하게 사후 처리할 수 있다.
%%writefile alloc_stat.f90
program alloc_stat
implicit none
integer, parameter :: i8 = selected_int_kind(18)
integer(i8) :: request
integer :: ierr
real, allocatable :: big(:)
request = 10_i8**11 ! deliberately too large
allocate(big(request), stat=ierr)
if (ierr /= 0) then
print *, "allocation failed, stat =", ierr
stop 1
end if
print *, "allocation succeeded"
deallocate(big)
end program alloc_stat
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall alloc_stat.f90 -o alloc_stat
!./alloc_stat
실행 결과:
allocation failed, stat = 5020
STOP 1
의도적인 대량의 메모리 할당: 이 프로그램은 오류 상황을 시뮬레이션하기 위해 10_i811($10^{11}$, 즉 $1,000$억 개)이라는 어마어마한 수치를 정수 변수 request에 담았다. 4바이트 단정밀도 실수형(real) 배열로 $1,000$억 개를 확보하려면 약 400GB 수준의 메모리가 필요한데, 이는 일반적인 계산 서버나 코랩 환경의 한계를 초과하는 크기다.
안전한 예외 처리: 만약 단순히 allocate(big(request))라고만 작성했다면 시스템은 메모리를 제공할 수 없어 "Segmentation fault"나 "Out of memory" 에러를 내며 비정상 종료되었을 것이다. 하지만 stat=ierr 덕분에, 운영체제로부터 메모리 제공을 거부를 당하는 순간 에러 코드(gfortran 기준 5020)를 ierr에 저장하며 프로그램의 제어권을 계속 유지할 수 있다.
시스템 전파 제어: 이어지는 if (ierr /= 0) 조건문이 할당 실패를 포착하여, 화면에 친절하게 실패 원인 문구를 출력해 준 뒤 stop 1을 작동시켜 결함이 있음을 알리며 프로그램을 방어 체계 안에서 제어했다.
[!warning] [흔한 실수] 큰 메모리를 다루거나 크기를 사용자 입력·파일에서 받는 코드에서는
stat=을 받는 습관을 들여야 한다.stat을 생략하고 할당이 실패하면 프로그램은 곧바로 비정상 종료한다.
allocated로 상태 확인하기동일한 allocatable 동적 변수를 해제하지 않은 상태에서 중복으로 할당(allocate)하거나, 이미 해제된 변수를 다시 해제(deallocate)하려고 시도하면 런타임 에러가 발생하며 프로그램이 즉시 비정상 종료된다.
따라서 동적 메모리를 다룰 때는 "현재 이 배열이 메모리에 정상적으로 할당되었는가?"를 사전에 점검해야만 안정성을 확보할 수 있다. Fortran은 이 검사를 정확히 수행하는 allocated() 내장 함수를 제공한다.
%%writefile check_allocated.f90
program check_allocated
implicit none
real, allocatable :: vals(:)
print *, "before:", allocated(vals)
allocate(vals(3))
vals = [1.0, 2.0, 3.0]
print *, "after allocate:", allocated(vals), " size:", size(vals)
if (allocated(vals)) deallocate(vals)
print *, "after deallocate:", allocated(vals)
end program check_allocated
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall check_allocated.f90 -o check_allocated
!./check_allocated
실행 결과:
before: F
after allocate: T size: 3
after deallocate: F
할당 상태 추적: 프로그램 초입의 선언 직후에는 메모리가 할당되지 않았으므로 allocated(vals)가 거짓(F)을 출력한다. 이후 정석적으로 allocate(vals(3))을 거치며 공간이 생성되면 비로소 참(T)으로 바뀐다.
할당 감시: 본 예제의 하단의 if (allocated(vals)) deallocate(vals) 문장은 수치 해석 프로그래밍에서 비정상 종료를 막기 위해 관용적으로 사용하는 코드이므로 외워두자. 이 조건문을 배치해 두면, 배열이 메모리에 할당 되어 있을 때만 해제하므로 런타임 에러를 방지할 수 있다.
[!warning] [흔한 실수] 런타임 에러를 부르는 동적 할당 실수들 이미 메모리를 할당받은
allocatable변수에 명시적 해제 없이allocate를 중복으로 호출하면 '이미 할당됨(already allocated)'이라는 치명적인 런타임 오류가 발생한다. 반대로, 메모리가 아직 확보되지 않은 미할당 상태의 배열에vals(1) = 0.0과 같이 원소 단위로 접근하거나deallocate를 시도하는 행위 또한 시스템 예외를 유발하며 프로그램이 즉시 강제 종료된다. 동적 변수의 할당 상태나 메모리 적격성이 모호할 때는 반드시allocated()내장 함수로 상태를 먼저 검증(validation)한 뒤 처리하는 습관을 들이자.
[!important] 9.2 할당 가능 대입과 자동 재할당
현대 Fortran에는 코드를 극적으로 간결하게 만들어 주는 강력한 메모리 편의 기능이 존재한다. 아래 문법 규격과 같이 allocatable 속성이 부여된 동적 변수에 배열을 그대로 대입(=)하면, 좌변의 배열이 우변 데이터의 형상(shape)과 크기에 맞춰 컴파일러 전산 시스템 내부에서 자동으로 메모리를 확보한다. 이는 개발자가 번거롭게 allocate 문장으로 크기를 사전에 규정하는 단계를 생략해도 된다는 의미다.
real, allocatable :: u(:)
u = [1.0, 2.0, 3.0] ! 별도의 allocate 명령 없이도 변수 u는 크기 3으로 자동 할당됨
만약 이미 메모리에 할당된 상태의 변수에 전혀 다른 형상이나 크기를 가진 배열을 대입할 경우, 좌변 배열의 기존 메모리를 스스로 해제한 뒤 새로운 우변의 규격에 맞춰 공간을 완전히 다시 재편한다. 이런 메커니즘을 할당 가능 대입 시의 자동 재할당(reallocation on assignment)이라고 한다.
좌변 변수가 allocatable 속성을 지닌 동적 배열일 때, 대입 연산자(=)가 작동하는 방식은 다음과 같다.
좌변이 미할당 상태인 경우: 우변 배열의 형상과 크기를 고스란히 물려받아 시스템 메모리가 자동으로 최초 할당된다.
좌변이 이미 할당되었으나 우변과 형상이 다른 경우: 기존에 할당된 메모리 공간을 즉시 해제(deallocate)한 후, 우변의 새로운 규격에 맞춰 공간을 완전히 재할당한다.
좌변이 이미 할당되어 있고 우변과 형상까지 완벽히 일치하는 경우: 추가적인 공정 없이, 기존 공간을 그대로 유지한 채 우변의 데이터 값만 메모리에 고속 복사한다.
아래는 명시적인 allocate나 deallocate 구문을 전혀 사용하지 않고, 오직 대입 연산자(=)만을 활용하여 배열의 메모리를 최초로 확보한 뒤 할당 크기를 유연하게 바꾸는 예시이다.
%%writefile alloc_assign.f90
program alloc_assign
implicit none
real, allocatable :: u(:), w(:)
u = [1.0, 2.0, 3.0, 4.0] ! auto-allocated to size 4
print *, "u size:", size(u), " u:", u
w = [10.0, 20.0] ! size 2
print *, "w size:", size(w)
w = [7.0, 8.0, 9.0, 5.0, 6.0] ! auto-reallocated to size 5
print *, "w size:", size(w), " w:", w
end program alloc_assign
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall alloc_assign.f90 -o alloc_assign
!./alloc_assign
실행 결과:
u size: 4 u: 1.00000000 2.00000000 3.00000000 4.00000000
w size: 2
w size: 5 w: 7.00000000 8.00000000 9.00000000 5.00000000 6.00000000
미할당 상태에서의 자동 최초 할당: 변수 u와 w는 선언 단계에서 물리 공간을 할당받지 못한 채 시작한다. 코드 내 u = [1.0, 2.0, 3.0, 4.0]에 도달하면, 시스템이 우변의 원소 개수가 4개임을 판정하여 u를 크기 4인 동적 배열로 최초 할당한다. 이어서 실행된 w = [10.0, 20.0] 구문 역시 동일한 원리로 w를 크기 2인 공간으로 빌드한다.
유연한 자동 재할당의 가동: 이미 크기가 2로 고정된 배열 w에 원소 5개 [7.0, 8.0, 9.0, 5.0, 6.0]을 대입한다. 컴파일러는 좌변과 우변의 형상이 불일치함에도 변수 w를 크기 5인 새로운 규격의 격자 배열로 자동으로 유연하게 갱신한다. 개발자가 수동으로 기존 메모리 해제와 신규 크기를 할당해야 했던 과정을 자동으로 처리한다.
오버헤드의 파격적인 감소: 위에서 배열 크기의 변화가 일어나는 동안 코드 내 어디에도 메모리 관리를 통제하기 위한 복잡한 조건문이나 수동 제어 구문이 개입하지 않았다. 이것이 코드의 가독성을 최고 수준으로 끌어올리는 현대 Fortran의 탁월함이다.
[!warning] [흔한 실수] 대입 연산 시 자동으로 배열의 크기가 조정되는 자동 재할당 기능은 오직 좌변의 변수가 동적 할당 배열(
allocatable)로 선언되었을 때만 작동한다. 아래 처럼 크기가 미리 정해진 고정 크기 배열에 형상(shape)이나 크기가 다른 값을 대입하려고 시도하면 컴파일 오류가 발생한다.
fortran real :: fixed(3) fixed = [1.0, 2.0] ! ERROR: shape (3) vs (2)gfortran컴파일러는 이런 형상 불일치 문제를 컴파일 단계에서 감지하여 차단한다. 이때 화면에는 다음 구체적인 에러 메시지가 출력된다.Different shape for array assignment at (1) on dimension 1 (expected 3 and got 2)따라서 배열 간의 대입 연산을 수행할 때는 좌변 변수가 고정 크기 배열인지 아니면 재할당이 가능한 동적 배열인지 명확히 인지하고, 고정 크기 배열일 경우 우변의 데이터 개수와 반드시 일치시켜야 한다.자동 재할당은 Fortran 2003 이후 표준이다. 일부 컴파일러나 옛 호환 옵션(예: gfortran의
-fno-realloc-lhs)에서는 이 동작이 비활성되어, 좌변 크기>가 유지되고 값이 잘리는 등 다른 결과가 나올 수 있는데, 표준 모드(-std=f2018)로 컴파일하면 안전하다.
프로그래밍을 하다 보면 조건에 맞는 원소를 하나씩 읽어들이면서 배열의 길이를 늘려나가는 작업이 자주 필요하다. Fortran에서는 배열 생성자와 자동 재할당 대입 기능을 결합하면 이를 매우 간결하게 코드로 표현할 수 있다.
아래 프로그램은 $1$부터 $20$까지의 정수 중, 제곱한 값이 짝수인 수들만 골라 배열에 차례대로 할당하는 예제이다. 수집될 데이터의 최종 개수를 미리 예측할 수 없으므로, 처음에는 크기가 $0$인 빈 배열로 시작한다.
%%writefile grow_array.f90
program grow_array
implicit none
integer, allocatable :: collected(:)
integer :: i, sq
allocate(collected(0)) ! start empty (size 0)
do i = 1, 20
sq = i*i
if (mod(sq, 2) == 0) then
collected = [collected, sq] ! grow by one element
end if
end do
print *, "count:", size(collected)
print *, "values:", collected
end program grow_array
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall grow_array.f90 -o grow_array
!./grow_array
실행 결과:
count: 10
values: 4 16 36 64 100 144 196 256 324 400
collected = [collected, sq] 구문이 실행될 때, 컴퓨터 내부에서는 "기존 배열의 모든 원소와 새로운 원소 sq 하나"를 결합한 새로운 임시 배열을 먼저 생성한다. 그 후, 자동 재할당 기능으로 좌변에 있던 collected 배열의 크기가 자동으로 한 칸 확장되면서 그 임시 배열의 값들이 그대로 대입된다.
[!important] 9.3 배열 크기에 따른 수렴 속도 및 정밀도 비교
배열의 크기를 물리적으로 키워 격자를 촘촘하게 구성하는 것만으로는 시뮬레이션의 정확도가 무한정 향상되지 않는다. 수치 해석 과정에서는 수학적 알고리즘 자체가 내포한 오차 수준과 컴퓨터 하드웨어가 가지는 부동소수점 정밀도의 한계선이 복합적으로 맞물려 최종 정확도를 결정하기 때문이다.
고성능 과학 계산과 기상 시뮬레이션에서 왜 상황에 맞는 유연한 동적 배열(allocatable)의 확보와 정밀한 종류 선택(kind)이 필수적인지 다음 예제와 시각화 결과를 통해 명확히 알 수 있다.
이 장의 서두에서 제시했던 문제 2 격자 해상도를 2배씩 키우며 수렴성 관찰하는 것을 직접 구현하고 검증해 보자. 수학적으로 참값이 $\pi \approx 3.141592653589793$로 알려진 다음의 정적분 식을 활용한다.
$$\int_0^1 \frac{4}{1+x^2}\,dx = \pi$$
이 적분을 수치적으로 계산하기 위해 가장 직관적인 격자 적분법인 사다리꼴 법칙(trapezoidal rule)을 적용한다. 구간의 수 $n$을 2부터 시작하여 매 루프마다 2배씩 기하급수적으로 증가($n = 2, 4, 8, \dots, 65536$)시킨다. 이때 격자 점의 개수에 맞춰 매번 정확히 길이가 $n+1$인 가변 표본 배열이 요구된다.
동일한 계산을 단정밀도(real32)와 배정밀도(real64)로 나란히 수행하여, 격자를 촘촘하게 키울 때 누적되는 컴퓨터 부동소수점 오차가 어떻게 다르게 변하는지 확인해 보자. 반복문(do k = 1, 16) 내부에서 각 차원마다 격자 배열이 어떻게 생성되고 소멸되는지 추적해 보자.
%%writefile convergence.f90
program convergence
implicit none
integer, parameter :: dp = selected_real_kind(15, 307)
real, allocatable :: xs(:), ys(:) ! single precision
real(dp), allocatable :: xd(:), yd(:) ! double precision
real :: hs, approx_s
real(dp) :: hd, approx_d, exact
integer :: k, n, i, u
exact = acos(-1.0_dp) ! true value = pi
open(newunit=u, file="convergence.csv", status="replace", action="write")
write(u, '(a)') "n,err_single,err_double"
do k = 1, 16
n = 2**k
allocate(xs(n+1), ys(n+1), xd(n+1), yd(n+1))
hs = 1.0 / real(n)
hd = 1.0_dp / real(n, dp)
do i = 0, n
xs(i+1) = real(i) * hs
xd(i+1) = real(i, dp) * hd
end do
ys = 4.0 / (1.0 + xs**2)
yd = 4.0_dp / (1.0_dp + xd**2)
approx_s = hs * (sum(ys) - 0.5 * (ys(1) + ys(n+1)))
approx_d = hd * (sum(yd) - 0.5_dp * (yd(1) + yd(n+1)))
write(u, '(i7,",",es16.8,",",es16.8)') n, &
abs(real(approx_s, dp) - exact), abs(approx_d - exact)
deallocate(xs, ys, xd, yd)
end do
close(u)
print *, "convergence.csv written"
end program convergence
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall convergence.f90 -o convergence
!./convergence
실행 결과 생성된 convergence.csv의 일부:
n,err_single,err_double
2, 4.15927490E-02, 4.15926536E-02
8, 2.60415872E-03, 2.60415910E-03
256, 2.05834443E-06, 2.54313151E-06
4096, 5.64259938E-07, 9.93410953E-09
65536, 2.53518159E-06, 3.88196142E-11
반복문에서 루프가 돌 때마다 네 개의 동적 배열(xs, ys, xd, yd)이 매번 새 크기로 allocate되고, 연산이 끝나는 블록 말단에서 deallocate되어 다음 번 루프의 가변 격자 할당에 대비한다. 만약 이 명시적 해제 과정을 단 한 줄이라도 누락하면, 두 번째 루프(k=2)에 진입하는 순간 중복 할당 예외가 발생하고 프로그램이 즉시 강제 종료된다.
코드 내부의 4.0_dp / (1.0_dp + xd2)와 같은 수식 및 sum 내장 함수는 8장에서 배운 전체 배열 연산(array expression)을 기반으로 작동하므로 루프 없이 초고속 병렬 계산이 수행된다.
출력된 convergence.csv 파일의 데이터를 해석하면 다음과 같다.
구간의 수 $n$이 $2$일 때, 단정밀도의 절대 오차는 $4.15927490 \times 10^{-2}$이며 배정밀도의 절대 오차는 $4.15926536 \times 10^{-2}$로 두 정밀도가 거의 유사한 오차 수준을 보인다.
구간의 수 $n$이 $8$로 증가할 때, 단정밀도의 오차는 $2.60415872 \times 10^{-3}$으로 줄어들고 배정밀도의 오차 역시 $2.60415910 \times 10^{-3}$으로 감소하며 사다리꼴 법칙의 수렴성을 증명한다.
격자가 촘촘해져 구간의 수 $n$이 $256$에 도달하면, 단정밀도의 오차는 $2.05834443 \times 10^{-6}$이 되고 배정밀도의 오차는 $2.54313151 \times 10^{-6}$을 기록하며 유의미한 정밀도 결합 양상을 유지한다.
격자 해상도를 더 키워 $n$이 $4096$이 되는 시점에는, 단정밀도의 오차가 $5.64259938 \times 10^{-7}$ 수준에서 정체되기 시작하는 반면 배정밀도는 $9.93410953 \times 10^{-9}$까지 오차가 현격하게 떨어지며 정밀도 간 격차가 벌어진다.
최고 해상도인 $n$이 $65536$에 이르면, 단정밀도는 반올림 오차의 누적으로 인해 오히려 오차가 $2.53518159 \times 10^{-6}$으로 거꾸로 증가하는 수치적 불안정성(numerical instability)이 일어나는 반면, 배정밀도는 한계선 없이 $3.88196142 \times 10^{-11}$이라는 극도로 미세한 영역까지 부드럽게게 하강하며 참값에 수렴한다.
수집된 격자별 오차 파일(convergence.csv)을 파이썬 코딩으로 수렴 그래프를 만들어 보자. 가로축과 세로축을 모두 로그 스케일로 설정한다.
import csv
import matplotlib.pyplot as plt
ns, es, ed = [], [], []
with open("convergence.csv") as f:
reader = csv.reader(f)
next(reader) # skip header
for row in reader:
ns.append(int(row[0]))
es.append(float(row[1]))
ed.append(float(row[2]))
plt.figure(figsize=(8, 5))
plt.loglog(ns, es, "o-", label="single precision (real)")
plt.loglog(ns, ed, "s-", label="double precision (dp)")
plt.xlabel("n (number of intervals)")
plt.ylabel("|approximation - pi|")
plt.title("Trapezoidal integration error vs array size n")
plt.legend()
plt.grid(True, which="both")
plt.savefig("convergence.png", dpi=120)
plt.show()
![[Pasted image 20260711201524.png]]
배정밀도(■) 곡선: 그래프 상에서 배정밀도 선은 기울기 약 $-2$를 유지하는 직선 형태로 하강한다. 사다리꼴 법칙의 알고리즘 오차가 $O(1/n^2)$의 수학적 규칙을 따르기 때문에, 격자의 수 $n$을 2배 키우면 연산 오차가 대략 $1/4$ 꼴로 정확히 감소하는 것이다.
단정밀도(●) 곡선: 단정밀도 선은 초기 격자 구간인 $n \approx 256$ 지점까지는 배정밀도 곡선과 겹치며 하강한다. 그러나 그 한계선 약 $1 \times 10^{-6}$ 부근에서 하강을 멈추고 기울기가 평평해 지고 들쭉날쭉 요동친다. 이는 단정밀도 부동소수점이 지닌 반올림 오차(round-off error)가 사다리꼴 법칙 알고리즘 오차를 넘었기 때문이다. 이 불안정 지점부터는 격자 배열의 크기를 아무리 키워도 정확도가 조금도 개선되지 않는다.
[!important] 오류 학습
반복문 내부에서 매 루프마다 해상도나 크기가 유동적으로 변하는 동적 배열을 새로 생성하여 연산하는 루프 구조는 수치 해석 프로그램에서 매우 흔하게 활용된다.
아래는 매 루프마다 allocate를 통해 새로운 물리 공간을 확보하면서도, 사용이 끝난 자원을 반환하는 deallocate 명령을 빠트렸을 때 발생하는 치명적인 런타임 오류 현상이다.
%%writefile loop_no_dealloc.f90
program loop_no_dealloc
implicit none
real, allocatable :: x(:)
integer :: k, n
do k = 1, 3
n = 2*k
allocate(x(n)) ! 2nd iteration: x is still allocated
x = real(k)
print *, "k =", k, " size =", size(x)
! deallocate(x) is missing
end do
end program loop_no_dealloc
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall loop_no_dealloc.f90 -o loop_no_dealloc
!./loop_no_dealloc
반복문의 첫 회 루프는 정상으로 보이지만 두 번째 루프에서 멈춘다.
k = 1 size = 2
At line 8 of file loop_no_dealloc.f90
Fortran runtime error: Attempting to allocate already allocated variable 'x'
첫 번째 루프(k = 1)에서는 변수 x가 미할당 상태였으므로 allocate(x(2))가 정상적으로 작동되어 크기 2의 격자가 생성되고 값이 출력된다.
앞에서 배운 동적 배열의 자동 해제(auto-deallocate) 기능은 반복문 블록의 끝이 아니라, 해당 변수가 선언된 프로그램 단위(여기서는 메인 프로그램)가 완전히 끝나는 시점에 비로소 작동한다. 따라서 첫번째 루프가 끝나고 두 번째 루프(k = 2)의 헤더를 통과하여 다시 allocate(x(4))를 마주하는 순간 문제가 발생한다. 변수 x는 여전히 크기 2인 메모리를 할당 받은 상태이므로, 결과적으로 컴파일러는 "이미 메모리를 점유하고 있는 동적 변수에 대고 또다시 할당을 감행했다"고 판단하여 Attempting to allocate already allocated variable 메시지를 내고 프로그램을 강제 종료한다.
이 문제는 명시적 자원 반환이라는 정석적인 방법으로 해결할 수 있다. 루프가 끝나기 직전, deallocate(x)를 명시적으로 호출하여 다음 반복 루프의 신규 할당이 방해받지 않도록 메모리를 깨끗하게 비우는 것이다.
do k = 1, 3
n = 2*k
allocate(x(n))
x = real(k)
print *, "k =", k, " size =", size(x)
deallocate(x) ! 메모리를 즉시 청소하여 다음 회차의 새로운 allocate에 완벽히 대비한다
end do
[!important] 요약
type, allocatable :: a(:) — 콜론 개수가 계수(rank).allocate(a(n)), deallocate(a). 안전 장치: allocate(a(n), stat=ierr).allocated(a) → 논리값. if (allocated(a)) deallocate(a) 관용구.allocatable 변수는 스코프를 벗어날 때 자동 해제된다(포인터는 그렇지 않다 — 13장).allocatable 좌변은 우변 형상에 맞춰 자동으로 (재)할당된다(-std=f2018에서 표준).v = [v, x], 대량은 용량 두 배 + call move_alloc(tmp, v).allocate 한다면 회차 끝에서 deallocate 하거나 할당가능 대입을 쓴다.x(:)가 호출마다 형상을 받고, 자동 배열 work(size(x))가 임시로 생긴다. 정식 문법은 10~12장.[!important] 연습 문제
n을 표준 입력으로 받아, 1부터 n까지의 정수를 담는 allocatable 배열을 만들고 그 합을 출력하라. 마지막에 deallocate도 호출하라.a(:)를 선언하고, a = [3.5, 1.2, 4.8, 2.1]처럼 할당가능 대입으로 채운 뒤 size(a)와 maxval(a)를 출력하라.allocatable 배열 x에 대해, 할당 전·후·해제 후 각각 allocated(x)의 값을 출력하는 프로그램을 작성하라.m(:,:)를 allocate하고, 모든 원소를 0.0으로 채운 뒤 형상을 shape(m)으로 출력하라.stat=을 받아, 성공이면 크기를, 실패면 안내 문구를 출력하라.v = [v, k]로 모아 개수와 값을 출력하라.n을 받아 길이 n+1인 실수 배열에 $x_i = i/n$ ($i=0,\dots,n$)을 채우고, y = sin(2*pi*x)를 계산해 wave.csv로 저장하라. Python으로 선그래프를 그려라.allocatable 배열 v를 크기 4로 할당해 채운 뒤, 같은 변수에 크기 7짜리 값을 대입(자동 재할당)하고, 재할당 전후의 size(v)를 출력해 크기가 바뀌었음을 보여라.allocatable 배열 a, b를 받아(대입으로 초기화), c = [a, b]로 이어 붙인 배열을 만들고 size(c)가 size(a)+size(b)와 같은지 확인하라.