[!attention] 이 장의 목표
- 전체 배열 연산 — 반복문 없이 배열 전체를 한 번에 계산한다.
- 조건부 연산 — where 구문 마스킹을 이용해 선택적으로 연산한다.
- 내장 함수 활용 — sum, matmul, transpose, reshape 등으로 배열을 다룬다.
지금까지는 배열의 값을 다룰 때 여전히 do 루프를 사용하여 각 요소에 하나씩 접근하며 반복 연산을 수행했다. 이번 장에서 학습할 핵심 내용은 루프를 사용하지 않고 배열 전체를 한 번에 계산하는 방법이다.
다음의 두 가지 연산 문제를 살펴보자.
문제 1: 길이가 1000인 두 배열 a와 b를 더하여 배열 c에 저장한다. 이때 do i = 1, 1000 루프를 돌리는 대신, c = a + b라는 단 한 줄의 코드로 끝낼 수는 없을까?
문제 2: $60 \times 60$ 크기의 격자 데이터 중 음수 형태의 값만 모두 0으로 변환하고자 한다. if 문을 포함한 이중 do 루프를 설계하는 대신, 간단한 조건식 한 줄로 이를 처리할 수는 없을까?
Fortran은 배열을 하나의 독립된 값처럼 다룰 수 있는 문법을 제공한다. 사칙연산과 같은 기본적인 산술 연산자는 물론이고 sin, sqrt 등의 수학 내장 함수, 합계나 최댓값을 구하는 통계 함수, 그리고 행렬 곱셈(matrix multiplication)까지 배열 전체에 곧바로 적용할 수 있다.
이런 접근법은 코드의 길이를 획기적으로 줄여줄 뿐만 아니라, 컴파일러가 내부적으로 벡터화(vectorization) 및 최적화를 수행하기에 매우 유리한 환경을 제공한다. 과학 및 공학 계산 분야에서 Fortran이 여전히 독보적인 강세를 유지하는 이유 중 하나가 바로 이 전체 배열 연산(whole-array operation) 기능이다.
[!important] 8.1 요소별 연산과 전체 배열 연산
배열에 산술 연산자를 적용하면 배열 내의 동일한 위치(index)에 있는 요소끼리 계산한다. 이를 요소별 연산(element-by-element operation)이라 한다. 크기와 차원이 동일한 두 배열 a와 b가 있을 때, 다음 대입 연산은 배열의 전체 길이에 대한 덧셈을 한 번에 수행한다.
c = a + b ! 모든 인덱스 i에 대해 c(i) = a(i) + b(i) 수행
덧셈(+)뿐만 아니라 뺄셈(-), 곱셈(*), 나눗셈(/), 거듭제곱(**) 등의 산술 연산자 역시 모두 요소별로 동작한다. 이때 가장 중요한 핵심 규칙은 두 배열의 형상 정합(shape conformance) 이다. 즉, 연산에 참여하는 두 배열은 서로 차원의 수(rank)와 각 차원의 크기(extent)가 정확히 일치해야만 연산이 가능하다. 만약 형상이 서로 다르면 컴파일 오류가 발생한다.
만약 배열과 단일 값인 스칼라(scalar)를 섞어서 연산하는 경우에는, 스칼라 값이 배열의 모든 원소에 동일하게 적용되도록 컴파일러가 내부적으로 배열의 형상만큼 확장하여 연산한다. 이를 스칼라 확장(scalar expansion) 이라 한다.
c = 2.0 * a + 1.0 ! 모든 인덱스 i에 대해 c(i) = 2.0 * a(i) + 1.0 수행
또한 sin, cos, sqrt, exp, abs 등과 같은 포트란의 표준 수학 내장 함수들은 대부분 요소별 함수(elemental function)로 설계되어 있다. 따라서 기존에 스칼라 변수를 인수로 넣던 방식 그대로 배열 변수를 인수로 전달할 수 있다. 예를 들어, sqrt(b)를 호출하면, 배열 b에 포함된 각 요소의 제곱근($\sqrt{x}$)을 계산하여 그 결과값들을 동일한 형상의 배열로 반환한다.
배열의 문법 규격은 아래와 같다.
array = array_expression
정합성(conformance): array_expression 내의 모든 배열 피연산자는 형상(shape, 크기 및 차원)이 동일해야 한다. 정합하지 않은 배열끼리의 연산은 컴파일 또는 실행 시 오류를 발생시킨다.
스칼라 확장(scalar extension): 스칼라 값은 임의의 배열과 연산이 가능하다. 이때 스칼라는 배열의 모든 요소에 동일하게 적용된다.
array = array + 1.0 (배열의 모든 요소에 1.0을 더함)대입 규칙: 대입문의 좌변 배열 형상은 우변의 결과물(배열 연산 결과)과 반드시 동일해야 한다.
Fortran의 배열 연산은 개발자가 명시적인 do 루프를 작성하지 않고도 배열 전체에 대해 직관적인 수학적 연산을 수행하도록 돕는 매우 강력하고 효율적인 기능이다.
예제를 통해 배열 연산 방법을 구체적으로 익혀보자.
%%writefile whole_array.f90
program whole_array
implicit none
integer, parameter :: n = 5
real :: a(n), b(n), c(n)
integer :: i
a = [(real(i), i = 1, n)] ! 1.0 2.0 3.0 4.0 5.0
b = [(real(i)**2, i = 1, n)] ! 1.0 4.0 9.0 16.0 25.0
c = a + b ! element-wise addition
print '(a, *(f7.1))', "a + b =", c
c = a * b ! element-wise multiplication
print '(a, *(f7.1))', "a * b =", c
c = 2.0 * a + 1.0 ! scalar broadcast over the array
print '(a, *(f7.1))', "2a + 1 =", c
c = sqrt(b) ! elemental intrinsic applies per element
print '(a, *(f7.1))', "sqrt(b) =", c
end program whole_array
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall whole_array.f90 -o whole_array
!./whole_array
실행 결과:
a + b = 2.0 6.0 12.0 20.0 30.0
a * b = 1.0 8.0 27.0 64.0 125.0
2a + 1 = 3.0 5.0 7.0 9.0 11.0
sqrt(b) = 1.0 2.0 3.0 4.0 5.0
반복 출력 서식 기술자: 출력 포맷에 사용된 *(f7.1)은 출력 버퍼(buffer: 데이터들을 순서대로 쌓아두는 임시 메모리 저장소)에 남은 데이터 원소가 존재하는 한 f7.1 서식을 계속 반복하라는 의미이다. 이 표현을 활용하면 배열의 길이를 일일이 명시하지 않아도 배열 전체 데이터를 한 번에 출력할 수 있다.
루프 없는 배열 연산의 이점: 제시된 연산 수행 코드 내부에는 데이터를 하나씩 처리하기 위한 명시적 do 루프가 단 한 줄도 포함되어 있지 않다. c = a + b 구조는 내부적으로 크기 n만큼의 요소별 덧셈 연산을 일괄 수행한다. 이런 전체 배열 연산 기법은 다루어야 할 데이터 배열의 규모가 커질수록 소스 코드의 가독성을 극대화하고 컴파일러의 자동 최적화를 유도하는 데 큰 강점을 제공한다.
[!warning] [흔한 실수] 형상이 다르면 배열 연산 불가 두 배열을 더하는 등의 연산을 수행하려면 형상이 같아야 한다. 길이 5와 4를 더하려 하면 컴파일러가 에러를 출력한다.
fortran real :: a(5), b(4), c(5) c = a + b ! shapes [5] and [4] are not conformablegfortran 메시지:
Error: Shapes for operands at (1) and (2) are not conformable정합 오류는 대개 인덱스 실수나 잘못된 크기 선언에서 기인한다. 슬라이싱을 할 때도 좌변과 우변의 크기(길이)를 일치시켜야 한다. 예를 들어,
a(2:5) = b(1:4)는 둘 다 길이 4라 정합이다.[!warning] [흔한 실수] 정수 나눗셈은 배열에서도 정수다
real배열이 아니라integer배열을 나누면 요소마다 정수 나눗셈이 일어난다. 즉,[(i, i=1,4)] / 2는[0, 1, 1, 2]가 된다. 실수 결과가 필요하면 피연산자를 실수로 만들어야 한다(real(...)사용). 4장의 정수 나눗셈 규칙이 배열에서도 똑같이 적용된다.
[!important] 8.2
where구문과 마스킹
배열 데이터를 처리할 때, 특정 조건을 만족하는 요소에만 선택적으로 연산을 수행해야 하는 경우가 자주 있다. 예를 들어, 격자 데이터 중 음수 값만 0으로 일괄 변환하거나, 특정 임계값(threshold)을 초과하는 이상치(outlier)만 낮추는 등의 작업이 이에 해당한다. 이런 연산은 if 문을 포함한 do 반복문의 조합으로도 구현할 수 있지만, 이 절에서 배우는 where 구문을 사용하여 더욱 직관적이고 효율적인 코딩이 가능하다.
where 구문은 각 요소의 참/거짓 상태를 담은 논리 배열(logical array)을 인수로 받아, 해당 위치의 값이 참(.true.)인 요소에만 대입 연산을 선택적으로 적용한다.
이때 기준이 되는 논리 배열을 마스크(mask)라고 부른다. 마스크는 x < 0.0과 같은 관계식을 배열 전체에 적용하여 얻은 결과물로, 원본 배열과 동일한 차원과 크기(형상)를 가지는 논리형 배열 구조를 가진다.
where 문특정 조건을 만족하는(참인) 요소에 대해서만 대입 연산을 단 한 줄로 명시한다.
where (mask) array = expression
이 예제는 where 문을 사용한 음수 클리핑(negative clipping)은 데이터 배열에서 0보다 작은 값(음수)을 모두 찾아내어 0(또는 사용자가 지정한 특정 값)으로 강제로 바꾸는 기법을 소개한다. 여기서 '클리핑(clipping)'은 특정 범위를 벗어나는 데이터의 끝을 잘라내어 지정한 범위에 맞추는 것을 의미한다.
%%writefile where_basic.f90
program where_basic
implicit none
integer, parameter :: n = 8
real :: x(n), y(n)
integer :: i
x = [(-4.0 + real(i), i = 0, n - 1)] ! -4 -3 -2 -1 0 1 2 3
y = x
where (y < 0.0) ! mask: which elements are negative
y = 0.0 ! assign only to the masked elements
end where
print '(a, *(f6.1))', "x =", x
print '(a, *(f6.1))', "y =", y ! negatives clipped to 0
end program where_basic
실행 결과:
x = -4.0 -3.0 -2.0 -1.0 0.0 1.0 2.0 3.0
y = 0.0 0.0 0.0 0.0 0.0 1.0 2.0 3.0
배열 초기화: 묵시적 do 루프를 통해 배열 x에 -4.0부터 3.0까지의 실수 값을 차례대로 채워 넣는다.
마스크 평가와 대입: where (y < 0.0) 구문이 실행될 때, 컴파일러는 배열 y를 평가하여 [.true., .true., .true., .true., .false., .false., .false., .false.]라는 내부 논리 마스크를 생성한다.
결과 확인: 마스크 값이 참(.true.)인 앞선 4개의 음수 위치에만 0.0이 일괄적으로 대입(clipping)되며, 0.0 이상인 나머지 요소들은 마스크가 거짓(.false.)이므로 원래래 값이 그대로 보존된다.
where 문조건에 따른 대입 연산이 여러 줄이거나, 여러 단계 조건 분기가 필요할 때 구조적 제어와 가독성을 제공하기 위해 사용한다.
where (mask1)
assignments_1
[elsewhere (mask2)
assignments_2]
[elsewhere
assignments_3]
end where
mask (마스크): 연산 대상이 되는 배열과 차원 및 크기(shape)가 완벽히 일치해야 하는 논리 배열식이다. 마스크 배열의 원소 값이 참(.true.)인 경우에만 대입 연산이 실행된다.
elsewhere: 앞선 마스크 조건이 거짓(.false.)인 경우에 후속 연산을 정의한다.
elsewhere (mask2)와 같이 새로운 조건을 덧붙이면 if-else if 구조처럼 다단계 필터링 분기가 가능하다. elsewhere만 단독으로 사용하면, 이전 모든 조건에 해당되지 않는 '나머지 모든 요소'를 대상으로 연산이 수행된다.이 구문은 다차원 격자 데이터에서 특정 조건에 부합하는 물리량만 추출할 때 매우 유용하게 활용된다. 주의 사항은 다음과 같다.
형상 정합성(shape conformance): where 구문 내부에 등장하는 모든 배열(마스크 조건식, 피연산자, 대입 결과 배열)은 반드시 동일한 형상을 가져야 한다. 형상이 다르면 컴파일 오류가 발생한다.
제한 사항: where 블록 내부에는 원칙적으로 배열의 대입 연산(assignment)만 올 수 있으며, 일반적인 print 문이나 do 반복문 같은 제어문을 혼용할 수 없다.
최적화와 성능: where 구문은 명시적 do 루프 내부에 if 문을 배치하는 것 보다 컴파일러 최적화에 훨씬 유리하다. 따라서, 배열 전체 요소에 대해 하드웨어 수준의 병렬 연산을 유도할 수 있어 대규모 과학 계산에서 매우 효율적으로 사용된다.
elsewhere)%%writefile where_else.f90
program where_else
implicit none
integer, parameter :: n = 6
real :: x(n), sgn(n)
x = [-2.0, -1.0, 0.0, 1.0, 2.0, 3.0]
where (x > 0.0)
sgn = 1.0
elsewhere (x < 0.0)
sgn = -1.0
elsewhere
sgn = 0.0
end where
print '(a, *(f6.1))', "x =", x
print '(a, *(f6.1))', "sgn =", sgn
end program where_else
실행 결과:
x = -2.0 -1.0 0.0 1.0 2.0 3.0
sgn = -1.0 -1.0 0.0 1.0 1.0 1.0
다단계 분기 제어: where 와 elsewhere 결합 구조를 활용하여 전체 데이터 배열을 양수, 음수, 그리고 나머지(0)라는 세 가지 영역으로 분류한다.
마스크 순차 적용:
where (x > 0.0) 단계에서 양수 데이터 위치를 찾아 sgn 배열의 해당 칸에 1.0을 대입한다. elsewhere (x < 0.0) 단계에서는 앞선 양수 필터링에서 제외된 요소 중 음수 데이터 위치를 찾아 -1.0을 대입한다. elsewhere 블록은 두 조건을 모두 만족하지 못한 나머지 요소, 즉 값이 0.0인 위치를 최종 식별하여 0.0을 채워 넣는다.복잡하고 다양한 분기 조건을 처리할 때는 where 구문을 활용하는 것이 코드의 가독성을 높이고 개발자의 구현 의도를 명확하게 드러내는 데 훨씬 유리하다.
[!warning] 흔한 실수
where는 if가 아니다where의 마스크는 배열 전체에 대한 요소별 조건이지,if처럼 한 번 평가되는 스칼라 조건이 아니다.where (n > 0)처럼 스칼라를 넣으면 의도와 다르게 동작하거나 오류가 난다. 분기 전체를 건너뛸지 결정하는 일반 조건문은if, 요소별 선택 대입은where로 구분해서 써야함을 주의하자.
[!important] 8.3 배열 내장 함수
배열을 한 번에 요약하거나 변형하는 내장 함수들을 크게 축약 함수, 형상 함수, 선형대수 함수로 구분할 수 있다.
축약 함수는 배열 데이터를 인수로 받아 가공하여 하나의 단일 값인 스칼라(또는 원본보다 더 낮은 차원 배열)로 변환하여 돌려주는 함수이다. Fortran에서 제공하는 대표적인 축약 함수들의 기능은 다음과 같다.
sum(a) / product(a): 배열 내 모든 요소의 총합 또는 총곱을 계산한다. maxval(a) / minval(a): 배열 내 전체 요소 중 최댓값 또는 최솟값을 반환한다. maxloc(a) / minloc(a): 배열 내 최댓값 또는 최솟값이 위치한 인덱스(첨자)를 찾아 반환한다. count(mask): 지정한 마스크(논리 배열) 조건에서 참(.true.)인 요소의 총 개수를 구한다. any(mask) / all(mask): 마스크 조건 중 단 하나라도 참인지 판별하거나, 모든 요소가 참인지 여부를 검사하여 논리값(.true. / .false.)으로 반환한다.아래 예제로 축약 함수들을 다루어 보자.
%%writefile reductions.f90
program reductions
implicit none
integer, parameter :: n = 6
real :: a(n)
a = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0]
print '(a, f6.1)', "sum(a) = ", sum(a)
print '(a, f8.1)', "product(a) = ", product(a)
print '(a, f6.1)', "maxval(a) = ", maxval(a)
print '(a, f6.1)', "minval(a) = ", minval(a)
print '(a, i0)', "maxloc(a) = ", maxloc(a, dim=1)
print '(a, i0)', "count(a>3) = ", count(a > 3.0)
print '(a, l1)', "any(a>8) = ", any(a > 8.0)
print '(a, l1)', "all(a>0) = ", all(a > 0.0)
end program reductions
실행 결과:
sum(a) = 23.0
product(a) = 540.0
maxval(a) = 9.0
minval(a) = 1.0
maxloc(a) = 6
count(a>3) = 3
any(a>8) = T
all(a>0) = T
조건 검색의 단순화: count, any, all 함수는 마스크 조건식과 결합할 때 강력한 효과를 발휘한다. 예를 들어 "기상 관측 데이터 중 설정된 임계 온도를 초과한 일수"를 산출하고 싶다면, 복잡한 루프를 설계할 필요 없이 count(temp > limit)라는 단 한 줄의 코드로 구현할 수 있다.
maxloc 인덱스 반환 규칙: maxloc(a) 함수는 다차원 배열 대응을 기본으로 설계되었기 때문에, 인수를 생략하면 크기가 1인 1차원 배열 형태로 결과값을 돌려준다. 따라서 1차원 배열에서 단일 정수 값으로 최댓값의 위치를 받고 싶다면, 예제와 같이 dim=1 옵션을 지정한다. 예제 배열 a에서 가장 큰 값인 9.0은 6번째 위치에 있으므로 결과값으로 정수 6이 출력된다.
reshape(source, shape) 함수는 1차원 배열 데이터를 사용자가 원하는 차원 형상으로 재배치하는 역할을 한다. 또한, sum과 같은 축약 함수를 사용할 때 dim 인수를 추가로 지정하면, 배열 전체를 하나의 스칼라로 만드는 대신 특정 차원만 선택적으로 줄여나갈 수 있다.
reshape와 차원 지정 축약%%writefile reshape_dim.f90
program reshape_dim
implicit none
integer :: m(2, 3)
integer :: j
! reshape fills column by column (column-major order)
m = reshape([1, 2, 3, 4, 5, 6], [2, 3])
print '(a)', "matrix m (filled column-major):"
do j = 1, 2
print '(3i4)', m(j, :) ! print row j
end do
print '(a, i0)', "sum(m) = ", sum(m)
print '(a, 3i4)', "sum dim=1 = ", sum(m, dim=1) ! collapse rows
print '(a, 2i4)', "sum dim=2 = ", sum(m, dim=2) ! collapse cols
end program reshape_dim
실행 결과:
matrix m (filled column-major):
1 3 5
2 4 6
sum(m) = 21
sum dim=1 = 3 7 11
sum dim=2 = 9 12
데이터 배치 (reshape): 1차원 배열 [1, 2, 3, 4, 5, 6]을 $2 \times 3$ 형상으로 변환할 때, 행이 아닌 열 단위로 데이터가 먼저 채워진다. 이에 따라 1과 2가 첫 번째 열, 3과 4가 두 번째 열, 5과 6이 세 번째 열을 구성하게 되어 결과적으로 위와 같은 $2 \times 3$ 행렬 구조가 완성된다.
차원 축소 (dim 인수): dim 인수는 해당 차원을 축소하여 제거한다.
sum(m, dim=1): 1차원(행 방향) 으로 축소, 제거한다. 세로축을 따라 더하므로 각 열의 합계인 [1+2, 3+4, 5+6]이 계산되어 [3, 7, 11]이라는 축소된 차원 1차원 배열이 반환된다. sum(m, dim=2): 2차원(열 방향) 으로 축소, 제거한다. 가로축을 따라 더하므로 각 행의 합계인 [1+3+5, 2+4+6]이 계산되어 [9, 12]라는 1차원 배열이 반환된다.[!warning] [흔한 실수] reshape는 행이 아니라 열부터 채운다. 출력된 형식이 일반적인 표 형식
[1,2,3,4,5,6]으로 정령되어 보인다고 행 우선([[1,2,3],[4,5,6]])이 아니다. Fortran은 열 우선이라[[1,3,5],[2,4,6]]이 된다. 행 우선처럼 할당하려면transpose하거나,reshape의order인수를 사용할 수 있다.
Fortran은 별도의 외부 라이브러리 없이도 기본적인 선형대수 연산을 수행할 수 있는 내장 함수를 제공한다. 아래는 몇 가지 예시 함수수이다.
matmul(a, b): 행렬 곱셈을 수행한다. 크기가 $(l \times m)$인 행렬과 $(m \times n)$인 행렬을 곱하여 $(l \times n)$ 크기의 행렬을 반환한다.
dot_product(u, v): 두 벡터의 내적(Dot product)을 계산하여 하나의 스칼라 값을 반환한다.
transpose(a): 2차원 배열의 행과 열을 서로 맞바꾸는 전치(Transpose) 연산을 수행한다.
matmul, dot_product, transpose%%writefile matmul_demo.f90
program matmul_demo
implicit none
real :: a(2, 3), b(3, 2), c(2, 2), at(3, 2)
real :: u(3), v(3)
integer :: j
a = reshape([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3])
b = reshape([1.0, 0.0, 1.0, 0.0, 1.0, 1.0], [3, 2])
c = matmul(a, b) ! (2x3) times (3x2) gives (2x2)
print '(a)', "matmul(a, b):"
do j = 1, 2
print '(2f7.1)', c(j, :)
end do
u = [1.0, 2.0, 3.0]
v = [4.0, 5.0, 6.0]
print '(a, f6.1)', "dot_product(u, v) = ", dot_product(u, v)
at = transpose(a) ! shape becomes (3x2)
print '(a)', "transpose(a):"
do j = 1, 3
print '(2f7.1)', at(j, :)
end do
end program matmul_demo
실행 결과:
matmul(a, b):
6.0 8.0
8.0 10.0
dot_product(u, v) = 32.0
transpose(a):
1.0 2.0
3.0 4.0
5.0 6.0
행렬 곱셈 조건 (matmul): 행렬 곱이 성립하려면 앞 행렬의 열 개수와 뒤 행렬의 행 개수(안쪽 차원)가 반드시 일치해야 한다. 예제에서 행렬 a $(2 \times 3)$와 행렬 b $(3 \times 2)$는 안쪽 차원인 3이 일치하므로 연산이 정의되며, 최종 결과물 c는 바깥쪽 차원을 따라 $(2 \times 2)$의 형상을 갖게 된다.
벡터 내적 계산 (dot_product): 크기가 같은 두 1차원 배열 u와 v를 받아 각 원소의 곱을 모두 더한다. 예제의 경우 $1 \times 4 + 2 \times 5 + 3 \times 6 = 32.0$이라는 스칼라 결과를 도출한다.
효율성 측면: 행렬 곱셈이나 벡터 내적을 중첩 do 반복문으로 직접 구현하지 않고 이런 내장 함수를 사용하면, 코드 내 수치해석적 의도가 명확해지고, 이미 최적화된 선형대수 라이브러리를 내부적으로 호출하므로 연산 속도에서 큰 이점을 얻을 수 있다.
[!warning] [흔한 실수]
a * b는 행렬 곱이 아니다 정사각 행렬이라 형상이 맞더라도a * b는 요소별 곱이지 행렬 곱이 아니다.
fortran real :: a(2,2), b(2,2) a = reshape([1.0, 2.0, 3.0, 4.0], [2, 2]) b = reshape([5.0, 6.0, 7.0, 8.0], [2, 2]) print '(a, 4f7.1)', "a * b = ", a * b ! 5 12 21 32 print '(a, 4f7.1)', "matmul(a,b) = ", matmul(a, b) ! 23 34 31 46두 결과는 전혀 다르다. 행렬 곱이 필요하면 반드시
matmul을 쓴다.
matmul 함수를 통해 생성된 행렬을 격자 형태의 물리량 데이터로 간주하면 시각적으로 구조를 훨씬 빠르게 이해할 수 있다.
열벡터 $u$($\sin$ 함수)와 행벡터 $v$($\cos$ 함수)의 외적(outer product, $(n \times 1) \cdot (1 \times n) = (n \times n)$)을 수행하여 생성된 행렬 데이터를 csv로 내보내고 히트맵(heatmap)으로 시각화해 보자. 벡터 외적의 각 원소는 $m(i, j) = u(i) \times v(j)$로 계산되므로, 두 삼각함수의 곱이 만드는 독특한 물결무늬가 격자 상에 나타나게 된다.
%%writefile heatmap_data.f90
program heatmap_data
implicit none
integer, parameter :: n = 40
real, parameter :: pi = 3.14159265
real :: u(n, 1), v(1, n), m(n, n)
integer :: i, j, ux
! build a column vector u and a row vector v
do i = 1, n
u(i, 1) = sin(2.0 * pi * real(i - 1) / real(n - 1))
end do
do j = 1, n
v(1, j) = cos(2.0 * pi * real(j - 1) / real(n - 1))
end do
! outer product via matmul: (n x 1) times (1 x n) gives (n x n)
m = matmul(u, v)
open(newunit=ux, file="heatmap.csv", status="replace", action="write")
do i = 1, n
write(ux, '(*(f9.5, :, ","))') m(i, :) ! one matrix row per line
end do
close(ux)
print '(a, i0, a, i0)', "heatmap.csv written: ", n, " x ", n
end program heatmap_data
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall heatmap_data.f90 -o heatmap_data
!./heatmap_data
실행 결과 생성된 heatmap.csv 파일을 읽어 들여 파이썬 환경에서 격자 그래프로 표출하는 시각화 코드는 다음과 같다.
import numpy as np
import matplotlib.pyplot as plt
# loadtxt reads the whole matrix; data[i, j] == m(i+1, j+1) from Fortran
m = np.loadtxt("heatmap.csv", delimiter=",")
plt.figure(figsize=(8, 6))
plt.imshow(m, origin="lower", cmap="coolwarm", aspect="auto")
plt.colorbar(label="value")
plt.title("outer product u v^T computed with matmul")
plt.xlabel("column index j")
plt.ylabel("row index i")
plt.tight_layout()
plt.savefig("heatmap.png", dpi=120)
plt.show()
![[Pasted image 20260709171501.png]]
시각화된 히트맵을 살펴보면, 양수 영역은 붉은색으로 표현되고 음수 영역은 푸른색으로 대조되어 나타난다. $\sin$ 함수와 $\cos$ 함수의 주기적인 상호작용이 행렬 곱으로 결합되면서 네 개의 마루(peak)와 골(trough)이 바둑판 형태로 대칭 배열된 규칙적인 무늬를 형성한다.
이처럼 대규모 수치 연산 결과물을 행렬의 순수한 숫자 텍스트로만 대할 때보다, 2차원 공간 상의 형상으로 시각화할 때 데이터의 물리적 구조와 경향성을 명확하게 파악할 수 있다.
[!important] 오류 학습
행렬 곱셈(matmul)이 성립하려면 앞 행렬의 열 개수와 뒤 행렬의 행 개수가 반드시 일치해야 한다. 겉보기에 행렬의 크기나 형상이 비슷해 보여 무심코 연산자로 결합하면 컴파일 단계에서 오류가 발생한다.
%%writefile matmul_dim.f90
program matmul_dim
implicit none
real :: a(2, 3), b(2, 2), c(2, 2)
a = 1.0
b = 1.0
c = matmul(a, b) ! inner dims 3 and 2 do not match
print *, c
end program matmul_dim
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall matmul_dim.f90 -o matmul_dim
Error: Different shape on dimension 2 for argument 'matrix_a' and
dimension 1 for argument 'matrix_b' at (1) for intrinsic matmul
오류 원인: 에러 메시지는 matrix_a의 2번째 차원 크기와 matrix_b의 1번째 차원 크기가 서로 달라 내장 함수 matmul을 수행할 수 없음을 알린다. 현재 행렬 a는 $(2 \times 3)$ 크기로 곱셈에 참여하는 안쪽 차원(열)이 3인 반면, 행렬 b는 $(2 \times 2)$ 크기로 안쪽 차원(행)이 2이다. 즉, 두 결합 차원의 크기(3과 2)가 유효하게 매칭되지 않기 때문에 행렬 곱이 정의되지 않는다.
해결 방법: 연산이 성립되도록 행렬 간의 안쪽 차원을 일치시켜야 한다. 본 예제에서는 행렬 b의 선언 크기를 기존 $(2 \times 2)$에서 $(3 \times 2)$로 수정하면(real :: b(3, 2)), $(2 \times 3) \cdot (3 \times 2)$ 형태의 정상적인 행렬 곱이 성립된다.
where 구문에 스칼라 조건을 지정한 오류where 구문의 괄호 안에는 반드시 연산 대상 배열과 형상이 일치하는 논리 배열(마스크)이 위치해야 한다. 일반 if 문처럼 단일 논리값을 반환하는 스칼라 조건을 지정하면 컴파일 오류가 발생한다.
%%writefile where_scalar.f90
program where_scalar
implicit none
integer :: n
real :: y(5)
n = 3
y = 1.0
where (n > 0) ! mask must be a logical ARRAY, not a scalar
y = 0.0
end where
print *, y
end program where_scalar
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall where_scalar.f90 -o where_scalar
!./where_scalar
Error: WHERE/ELSEWHERE clause at (1) requires a LOGICAL array
오류 원인: 에러 메시지는 where 및 elsewhere 구문에서 논리형 배열(LOGICAL array)을 요구함을 명시하고 있다. 코드에서 사용된 n > 0이라는 조건식은 단 하나의 참(.true.) 또는 거짓(.false.)만을 가지는 스칼라 논리값이다. where 구문은 배열 내의 "어느 인덱스 요소에 연산을 대입할지"를 요소별로 판별하여 제어하는 문법이므로, 각 요소와 일대일 대응이 가능한 배열 형태의 마스크 조건식이 반드시 필요하다.
해결 방법: where 내부에 지정하는 제어식을 대입 대상 배열과 동일한 차원 및 크기를 가지는 배열 조건식(예: where (y > 0.0))으로 수정해야 한다.
[!important] 요약
+ - * / **를 쓰면 요소별로 계산된다. 루프가 필요 없다. 스칼라는 모든 요소에 확장된다(스칼라 확장).sin, sqrt, abs 등 수학 내장 함수는 배열에 그대로 적용된다.where 구문 — 마스크가 참인 요소에만 대입. elsewhere로 나머지·추가 조건 처리.sum, product, maxval, minval, maxloc, count, any, all. dim 인수로 특정 차원만 줄일 수 있다(sum(m, dim=1)은 열별 합).reshape(source, shape)는 열 우선으로 채운다.matmul, 내적은 dot_product, 전치는 transpose. a * b(요소별 곱)와 matmul(a, b)(행렬 곱)를 혼동하지 않는다.[!important] 연습 문제
a, b를 생성자로 채우고, a+b·a-b·a*b·a/b를 전체 배열 연산으로 계산해 각각 한 줄씩 출력하라.[-10.0, 0.0, 15.0, 25.0, 37.0]을 화씨로 변환하는 식 f = c * 9.0 / 5.0 + 32.0을 전체 배열 연산 한 줄로 적용해 출력하라. 정수 나눗셈이 섞이지 않도록 실수 리터럴을 쓴 이유를 한 줄로 설명하라.sum, maxval, minval, count(a > 0)를 구해 출력하라.where로 음수 요소만 절댓값으로 바꿔라(abs 사용 금지, where만으로).reshape로 [3, 4] 배열에 담아 행 단위로 출력하고, 열 우선으로 채워졌음을 출력으로 확인하라.dot_product로 구하고, do 루프로 직접 누적한 값과 같은지 비교 출력하라.n인 실수 배열을 표준화(z-점수)하라. 평균은 sum(a)/n, 표준편차는 편차 제곱합으로 구한다. 결과 배열의 평균이 0에 가깝고 표준편차가 1에 가까움을 출력으로 확인하라.2×3 행렬과 3×2 행렬을 matmul로 곱하고, 같은 곱을 삼중 do 루프로 직접 계산해 두 결과가 일치하는지 maxval(abs(...))로 검증하라.where/elsewhere로 구간 분류하라(예: 90 이상 3, 70 이상 2, 그 외 1). 분류 코드 배열을 출력하라.4×5 측정값 행렬에서 sum(m, dim=1)으로 열별 합, sum(m, dim=2)로 행별 합을 구해 출력하고, 두 합의 총계가 sum(m)과 같은지 확인하라.