자주 묻는 질문

자주 묻는 질문 (Frequently Asked Questions)

Julia를 쓰다 보면 여기저기서 마주치는 질문들이 있어요. 성능이나 공개 API 같은 설계 철학부터, 스크립트에서 CTRL-C 잡기, ... 연산자가 뭘 하는지, 타입 안정성이 뭔지 같은 실전적인 궁금증까지. 이번 장에서는 그런 질문들을 한곳에 모아 하나씩 풀어 볼게요. 어떤 내용은 이미 다른 장에서 다룬 주제와 이어지기도 하니, 필요할 때 해당 장을 함께 보면 더 좋아요.

출처: Julia 공식 문서: 자주 묻는 질문

일반 (General)

Julia는 사람이나 무언가를 따서 이름 지어진 건가요?

아니요.

왜 Matlab/Python/R/… 코드를 Julia로 컴파일하지 않나요?

많은 사람이 다른 동적 언어의 문법에 익숙하고, 이미 그 언어들로 작성된 코드가 많으니, 왜 Matlab이나 Python 프론트엔드를 Julia 백엔드에 연결해서(또는 코드를 Julia로 "트랜스파일"해서) 새 언어를 배울 필요 없이 Julia의 성능 이점을 다 얻지 않았는지 궁금해하는 건 자연스러워요. 간단해 보이죠?

기본적인 문제는 Julia 컴파일러에 특별한 게 없다는 거예요. 우리는 다른 언어 개발자도 아는 평범한 컴파일러(LLVM)를 쓰고, "비밀 소스"가 없습니다. 실제로 Julia의 컴파일러는 다른 동적 언어(PyPy나 LuaJIT 같은)보다 여러모로 훨씬 단순해요. Julia의 성능 이점은 거의 전적으로 프론트엔드에서 옵니다. 그 언어 시맨틱 덕분에 잘 작성된 Julia 프로그램이 컴파일러에 효율적인 코드와 메모리 레이아웃을 생성할 기회를 더 많이 주거든요. Matlab이나 Python 코드를 Julia로 컴파일하려고 하면, 우리 컴파일러는 Matlab이나 Python의 시맨틱에 제약을 받아 그 언어들의 기존 컴파일러보다 더 나은 코드(아마도 더 나쁜)를 만들어 내지 못할 거예요. 시맨틱의 핵심 역할은 기존 Python 컴파일러 몇 개(Numba, Pythran 같은)가 언어의 작은 부분 집합(예: Numpy 배열과 스칼라 연산)만 최적화하려고 하는 이유이기도 해요. 그 부분 집합에서는 이미 우리가 같은 시맨틱으로 낼 수 있는 만큼 이상을 해내고 있죠. 그 프로젝트에 참여하는 사람들은 엄청나게 똑똑하고 굉장한 일을 해냈지만, 인터프리터용으로 설계된 언어에 컴파일러를 얹는 건 아주 어려운 문제입니다.

Julia의 장점은 좋은 성능이 소수의 "내장" 타입과 연산 부분 집합에 국한되지 않고, 임의의 사용자 정의 타입에서 동작하는 고수준 타입-제네릭 코드를 빠르고 메모리 효율적으로 유지하면서 작성할 수 있다는 거예요. Python 같은 언어의 타입은 컴파일러에 비슷한 능력을 위한 충분한 정보를 주지 못하므로, 그 언어들을 Julia 프론트엔드로 쓰는 순간 막혀 버립니다.

비슷한 이유로, Julia로의 자동 번역도 보통 읽을 수 없고 느리며 비관용적인 코드를 만들어 내는 게 일반적이에요. 다른 언어에서 네이티브 Julia 포팅을 시작하기에 좋은 출발점이 되지 못하죠.

반면에 언어 간 상호 운용은 아주 유용합니다. Julia에서 다른 언어의 기존 고품질 코드를 활용하고 싶고, 그 반대도 마찬가지예요! 이를 가능하게 하는 최선의 방법은 트랜스파일러가 아니라 쉬운 언어 간 호출 기능입니다. 우리는 이걸 위해 열심히 작업해 왔어요. 내장 ccall 기본형(C와 Fortran 라이브러리 호출용)부터 Julia를 Python, Matlab, C++ 등과 연결하는 JuliaInterop 패키지까지요.

공개 API (Public API)

Julia는 공개 API를 어떻게 정의하나요?

Julia의 공개 APIBase와 표준 라이브러리의 공개 바인딩에 대한 문서에 기술된 동작이에요. 함수, 타입, 상수도 공개적이지 않으면 공개 API의 일부가 아닙니다. docstring이 있거나 문서에 기술되어 있어도요. 게다가 공개 바인딩의 문서화된 동작만이 공개 API의 일부입니다. 공개 바인딩의 문서화되지 않은 동작은 내부적인 것이에요.

공개 바인딩은 public foo 또는 export foo로 표시된 것들입니다.

다시 말해:

  • 공개 바인딩의 문서화된 동작은 공개 API의 일부입니다.
  • 공개 바인딩의 문서화되지 않은 동작은 공개 API의 일부가 아닙니다.
  • 비공개 바인딩의 문서화된 동작은 공개 API의 일부가 아닙니다.
  • 비공개 바인딩의 문서화되지 않은 동작은 공개 API의 일부가 아닙니다.
  • 모듈의 공개 바인딩 전체 목록은 names(MyModule)로 얻을 수 있습니다.

패키지 작성자도 자신의 공개 API를 비슷하게 정의하길 권장합니다.

Julia 공개 API의 모든 것은 SemVer의 적용을 받으므로, Julia 2.0 전에는 제거되거나 의미 있는 파괴적 변경을 겪지 않을 거예요.

유용한데 문서화되지 않은 함수/타입/상수가 있어요. 써도 되나요?

비공개 API를 쓰면 Julia를 업데이트할 때 코드가 깨질 수 있어요. 코드가 자급자족적이라면, 프로젝트로 복사해 넣는 게 좋은 생각일 수 있어요. 복잡한 비공개 API에 의존하고 싶다면, 특히 안정적인 패키지에서 쓸 때는, 그것을 공개 API로 바꾸는 논의를 시작하려고 이슈풀 리퀘스트를 여는 게 좋아요. 다만 Julia의 비공개 구현 세부 사항에 의존하면서도 안정적인 공개 인터페이스를 노출하고, 서로 다른 Julia 버전의 차이를 완충하는 패키지를 만드는 시도 자체를 막지는 않아요.

문서가 충분히 정확하지 않아요. 기존 동작에 의존해도 되나요?

기존 동작을 공개 API로 바꾸는 논의를 시작하려면 이슈풀 리퀘스트를 열어 주세요.

세션과 REPL (Sessions and the REPL)

메모리에서 객체를 삭제하려면 어떻게 하나요?

Julia에는 MATLAB의 clear 함수에 해당하는 게 없어요. 한번 이름이 Julia 세션에서(엄밀히는 Main 모듈에서) 정의되면 항상 존재합니다.

메모리 사용이 걱정이라면 객체를 메모리를 덜 쓰는 것으로 교체할 수 있어요. 예를 들어 A가 더 이상 필요 없는 기가바이트 크기 배열이라면 A = nothing으로 메모리를 해제할 수 있습니다. 메모리는 다음에 가비지 컬렉터가 실행될 때 해제돼요. GC.gc()로 실행을 강제할 수도 있습니다. 게다가 대부분의 메서드가 Nothing 타입에 정의되어 있지 않아서, A를 사용하려는 시도는 아마 오류로 이어질 거예요.

스크립팅 (Scripting)

현재 파일이 메인 스크립트로 실행되고 있는지 확인하려면 어떻게 하나요?

julia file.jl로 파일을 메인 스크립트로 실행할 때, 명령줄 인자 처리 같은 추가 기능을 활성화하고 싶을 수 있어요. 파일이 그런 식으로 실행되는지 판단하는 방법은 abspath(PROGRAM_FILE) == @__FILE__true인지 확인하는 거예요.

하지만 스크립트와 임포트 가능한 라이브러리를 겸하는 파일을 작성하는 건 권장되지 않아요. 라이브러리와 스크립트 양쪽으로 기능이 필요하다면, 라이브러리로 작성한 다음 그 기능을 별도의 스크립트로 임포트하는 게 더 좋아요.

스크립트에서 CTRL-C를 잡으려면 어떻게 하나요?

julia file.jl로 Julia 스크립트를 실행하면, CTRL-C(SIGINT)로 종료하려고 해도 InterruptException이 던져지지 않아요. Julia 스크립트가 종료되기 전에 특정 코드를 실행하려면(CTRL-C 때문일 수도 아닐 수도 있음) atexit을 써 보세요. 또는 julia -e 'include(popfirst!(ARGS))' file.jl로 스크립트를 실행하면 try 블록에서 InterruptException을 잡을 수 있어요. 이 전략에서는 PROGRAM_FILE이 설정되지 않는다는 점을 기억하세요.

#!/usr/bin/env로 julia에 옵션을 전달하려면 어떻게 하나요?

소위 shebang 줄에서 #!/usr/bin/env julia --startup-file=no처럼 julia에 옵션을 전달하는 건 많은 플랫폼(BSD, macOS, Linux)에서 동작하지 않아요. 그 플랫폼의 커널은 셸과 달리 인자를 공백 문자에서 나누지 않기 때문이에요. env -S 옵션은 셸과 비슷하게 단일 인자 문자열을 공백에서 여러 인자로 나누는데, 간단한 해결 방법을 제공합니다.

#!/usr/bin/env -S julia --color=yes --startup-file=no
@show ARGS # put any Julia code here

env -S 옵션은 FreeBSD 6.0(2005), macOS Sierra(2016), GNU/Linux coreutils 8.30(2018)에 등장했습니다.

외부 프로그램을 스크립팅할 때 run은 왜 *나 파이프를 지원하지 않나요?

Julia의 run 함수는 외부 프로그램을 운영체제 셸을 호출하지 않고 직접 실행해요. Python, R, C 같은 다른 언어의 system("...") 함수와 달리요. 즉 run*의 와일드카드 확장("globbing")을 수행하지 않고, |> 같은 셸 파이프라인도 해석하지 않습니다.

그래도 globbing과 파이프라인은 Julia 기능으로 할 수 있어요. 예를 들어 내장 pipeline 함수는 셸 파이프처럼 외부 프로그램과 파일을 연결할 수 있고, Glob.jl 패키지는 POSIX 호환 globbing을 구현합니다.

물론 run에 셸과 명령 문자열을 명시적으로 넘겨 셸을 통해 프로그램을 실행할 수도 있어요. 예를 들어 Unix Bourne shell을 쓰려면 run(sh -c "ls > files.txt")처럼요. 하지만 보통은 run(pipeline(ls, "files.txt")) 같은 순수 Julia 스크립팅을 선호해야 해요. 기본적으로 셸을 피하는 이유는 셸 아웃소싱은 별로여서입니다. 셸로 프로세스를 실행하는 건 느리고, 특수 문자 인용에 취약하고, 오류 처리가 나쁘며, 이식성에도 문제가 있어요. (Python 개발자들도 비슷한 결론에 도달했어요.)

변수와 할당 (Variables and Assignments)

단순한 루프인데 왜 UndefVarError가 나오나요?

이런 코드가 있을 수 있어요.

x = 0
while x < 10
x += 1
end

인터랙티브 환경(Julia REPL 같은)에서는 잘 동작하지만, 스크립트나 다른 파일에서 실행하려고 하면 UndefVarError: x not defined가 나오는 걸 볼 수 있죠. 무슨 일이냐면, Julia는 일반적으로 로컬 스코프에서 전역 변수에 할당하는 것에 대해 명시적일 것을 요구합니다.

여기서 x는 전역 변수이고, while로컬 스코프를 정의하며, x += 1은 그 로컬 스코프에서 전역 변수에 대한 할당이에요.

아까 말했듯이 Julia(버전 1.5 이상)는 탐색을 단순화하기 위해(예: 함수에서 코드를 복사 붙여넣기해서 인터랙티브하게 실행) REPL(및 다른 많은 인터랙티브 환경)의 코드에서 global 키워드를 생략하는 걸 허용합니다. 하지만 파일의 코드로 옮기면 Julia는 전역 변수에 대해 더 훈련된 접근을 요구해요. 최소한 세 가지 옵션이 있습니다.

  • 코드를 함수 안에 넣으세요. 그러면 x가 함수의 로컬 변수가 됩니다. 일반적으로 전역 스크립트보다 함수를 쓰는 게 좋은 소프트웨어 엔지니어링이에요("왜 전역 변수가 나쁜지"를 검색하면 설명이 많이 나옵니다). Julia에서 전역 변수는 느리기도 합니다.
  • 코드를 let 블록으로 감싸세요. let ... end 문 안에서 x가 로컬 변수가 되어 global이 필요 없어집니다.
  • 할당하기 전에 로컬 스코프 안에서 xglobal로 명시적으로 표시하세요. 예: global x += 1.

더 자세한 설명은 매뉴얼의 soft scope 절에서 찾을 수 있어요.

함수 (Functions)

인자 x를 함수에 넘겼는데, 그 함수 안에서 수정했는데도 밖에서는 변수 x가 여전히 바뀌지 않았어요. 왜죠?

이렇게 함수를 호출한다고 해 보죠.

julia> x = 10
10

julia> function change_value!(y)
y = 17
end
change_value! (generic function with 1 method)

julia> change_value!(x)
17

julia> x # x is unchanged!
10

Julia에서 변수 x의 바인딩은 x를 함수에 인자로 넘겨도 바꿀 수 없어요. 위 예시에서 change_value!(x)를 호출할 때, y는 새로 만들어진 변수로 처음에는 x의 값, 즉 10에 바인딩됩니다. 그다음 y는 상수 17에 다시 바인딩되지만, 바깥 스코프의 변수 x는 그대로 남아요.

하지만 xArray 타입(또는 다른 가변 타입)의 객체에 바인딩되어 있다면 얘기가 달라져요. 함수 안에서 x를 이 Array에서 "언바인딩"할 수는 없지만, 그 내용물은 바꿀 수 있습니다. 예를 들면

julia> x = [1,2,3]
3-element Vector{Int64}:
1
2
3

julia> function change_array!(A)
A[1] = 5
end
change_array! (generic function with 1 method)

julia> change_array!(x)
5

julia> x
3-element Vector{Int64}:
5
2
3

여기서 우리는 change_array!라는 함수를 만들어서, 넘겨진 배열(호출 지점에서 x에 바인딩되고 함수 안에서 A에 바인딩됨)의 첫 번째 요소에 5를 할당했어요. 함수 호출 후에도 x는 같은 배열에 바인딩되어 있지만, 그 배열의 내용은 바뀌었다는 점을 주목하세요. Ax는 같은 가변 Array 객체를 가리키는 서로 다른 바인딩이었던 거죠.

함수 안에서 using이나 import를 쓸 수 있나요?

아니요, 함수 안에는 using이나 import 문을 둘 수 없어요. 모듈을 임포트하고 싶지만 그 바인딩을 특정 함수나 함수 집합 안에서만 쓰고 싶다면, 두 가지 옵션이 있습니다.

import를 쓰는 방법:

import Foo
function bar(...)
# ... refer to Foo bindings via Foo.baz ...
end

이렇게 하면 모듈 Foo를 로드하고 그 모듈을 가리키는 변수 Foo를 정의하지만, 그 모듈의 다른 바인딩은 현재 네임스페이스로 가져오지 않아요. Foo의 바인딩은 정규화된 이름 Foo.bar 등으로 참조합니다.

  • 함수를 모듈로 감싸는 방법:
module Bar
export bar
using Foo
function bar(...)
# ... refer to Foo.baz as simply baz ....
end
end
using Bar

이렇게 하면 Foo의 모든 바인딩을 가져오지만, 오직 모듈 Bar 안에서만 그렇습니다.

... 연산자는 무엇을 하나요?

... 연산자의 두 가지 용도: 슬러핑과 스패팅

Julia 신입에게 ... 연산자의 사용은 종종 혼란스러워요. ... 연산자를 혼란스럽게 만드는 부분 중 하나는 문맥에 따라 두 가지 다른 의미를 갖는다는 점이에요.

...는 함수 정의에서 여러 인자를 하나의 인자로 합칩니다

함수 정의의 문맥에서 ... 연산자는 서로 다른 여러 인자를 단일 인자로 합치는 데 쓰입니다. 여러 인자를 하나로 합치는 이런 ... 사용을 슬러핑(slurping)이라고 불러요.

julia> function printargs(args...)
println(typeof(args))
for (i, arg) in enumerate(args)
println("Arg #$i = $arg")
end
end
printargs (generic function with 1 method)

julia> printargs(1, 2, 3)
Tuple{Int64, Int64, Int64}
Arg #1 = 1
Arg #2 = 2
Arg #3 = 3

만약 Julia가 ASCII 문자를 더 자유롭게 쓰는 언어였다면, 슬러핑 연산자는 ... 대신 <-...로 쓰였을지도 몰라요.

...는 함수 호출에서 하나의 인자를 여러 인자로 나눕니다

함수를 정의할 때 여러 인자를 하나로 슬러핑하는 ... 연산자의 용도와 대조적으로, ... 연산자는 함수 호출의 문맥에서 쓰일 때 단일 함수 인자를 여러 인자로 쪼개는 데도 사용됩니다. 이런 ... 사용을 스패팅(splatting)이라고 불러요.

julia> function threeargs(a, b, c)
println("a = $a::$(typeof(a))")
println("b = $b::$(typeof(b))")
println("c = $c::$(typeof(c))")
end
threeargs (generic function with 1 method)

julia> x = [1, 2, 3]
3-element Vector{Int64}:
1
2
3

julia> threeargs(x...)
a = 1::Int64
b = 2::Int64
c = 3::Int64

만약 Julia가 ASCII 문자를 더 자유롭게 쓰는 언어였다면, 스패팅 연산자는 ... 대신 ...->로 쓰였을지도 몰라요.

할당의 반환 값은 무엇인가요?

연산자 =는 항상 오른쪽을 반환합니다. 따라서

julia> function threeint()
x::Int = 3.0
x # returns variable x
end
threeint (generic function with 1 method)

julia> function threefloat()
x::Int = 3.0 # returns 3.0
end
threefloat (generic function with 1 method)

julia> threeint()
3

julia> threefloat()
3.0

그리고 비슷하게:

julia> function twothreetup()
x, y = [2, 3] # assigns 2 to x and 3 to y
x, y # returns a tuple
end
twothreetup (generic function with 1 method)

julia> function twothreearr()
x, y = [2, 3] # returns an array
end
twothreearr (generic function with 1 method)

julia> twothreetup()
(2, 3)

julia> twothreearr()
2-element Vector{Int64}:
2
3

!로 끝나는 함수가 할당해도 되나요?

네! !로 끝나는 함수 이름은 그 함수가 인자 중 적어도 하나(보통 첫 번째 인자)를 변형(mutate)한다는 뜻이에요. 하지만 계산을 빠르게 하거나 그 결과를 만들기 위해 스크래치 공간을 할당할 수도 있습니다.

타입, 타입 선언, 생성자 (Types, type declarations, and constructors)

"타입-안정적(type-stable)"이란 무엇인가요?

출력 타입이 입력 타입에서 예측 가능하다는 뜻이에요. 특히 출력 타입이 입력의 값에 따라 달라질 수 없다는 뜻이죠. 다음 코드는 타입-안정적이지 않습니다.

julia> function unstable(flag::Bool)
if flag
return 1
else
return 1.0
end
end
unstable (generic function with 1 method)

인자 값에 따라 IntFloat64를 반환해요. Julia는 이 함수의 반환 타입을 컴파일 시점에 예측할 수 없으므로, 이 함수를 사용하는 모든 계산이 두 타입의 값을 모두 다룰 수 있어야 하고, 이는 빠른 머신 코드를 만드는 걸 어렵게 합니다.

왜 Julia는 그럴듯해 보이는 특정 연산에 DomainError를 주나요?

수학적으로는 말이 되지만 오류로 이어지는 연산이 있어요.

julia> sqrt(-2.0)
ERROR: DomainError with -2.0:
sqrt was called with a negative real argument but will only return a complex result if called with a complex argument. Try sqrt(Complex(x)).
Stacktrace:
[...]

이 동작은 타입 안정성 요구의 불편한 결과예요. sqrt의 경우 대부분의 사용자는 sqrt(2.0)이 실수 값을 주길 원하고, 복소수 1.4142135623730951 + 0.0im이 나오면 불만일 거예요. sqrt 함수를 음수가 전달될 때만 복소수 값 출력으로 전환하도록 쓸 수도 있지만(다른 언어의 sqrt가 그렇게 하죠), 그러면 결과가 타입-안정적이지 않게 되어 sqrt 함수의 성능이 나빠질 거예요.

이런 경우들과 그 밖의 경우에, 결과를 표현할 수 있는 출력 타입을 받아들일 의향을 전달하는 입력 타입을 선택해서 원하는 결과를 얻을 수 있어요.

julia> sqrt(-2.0+0im)
0.0 + 1.4142135623730951im

타입 매개변수를 어떻게 제약하거나 계산하나요?

파라메트릭 타입의 매개변수는 타입이나 비트 값을 담을 수 있고, 타입 자신이 이 매개변수를 어떻게 활용할지 선택합니다. 예를 들어 Array{Float64, 2}는 원소 타입을 표현하기 위해 타입 Float64로, 차원 수를 표현하기 위해 정수 값 2로 파라메터화됩니다. 자신의 파라메트릭 타입을 정의할 때는 서브타입 제약을 사용해서 특정 매개변수가 어떤 추상 타입이나 이전 타입 매개변수의 서브타입(<:)이어야 한다고 선언할 수 있어요. 하지만 매개변수가 주어진 타입의 값이어야 한다고 선언하는 전용 문법은 없습니다. 즉, 예를 들어 차원 같은 매개변수가 struct 정의 안에서 Intisa한다고 직접 선언할 수 없어요. 마찬가지로 타입 매개변수에 (덧셈이나 뺄셈 같은 단순한 것도) 계산을 할 수 없습니다. 대신 이런 종류의 제약과 관계는 타입의 생성자 안에서 계산되고 강제되는 추가 타입 매개변수를 통해 표현할 수 있어요.

예를 들어 다음을 고려해 보죠.

struct ConstrainedType{T,N,N+1} # NOTE: INVALID SYNTAX
A::Array{T,N}
B::Array{T,N+1}
end

여기서 사용자는 세 번째 타입 매개변수가 항상 두 번째에 1을 더한 값이 되도록 강제하고 싶어 해요. 이것은 내부 생성자 메서드가 검사하는 명시적 타입 매개변수로 구현할 수 있습니다(다른 검사와 결합할 수도 있어요).

struct ConstrainedType{T,N,M}
A::Array{T,N}
B::Array{T,M}
function ConstrainedType(A::Array{T,N}, B::Array{T,M}) where {T,N,M}
N + 1 == M || throw(ArgumentError("second argument should have one more axis" ))
new{T,N,M}(A, B)
end
end

컴파일러가 유효한 구체 타입에 대해 검사를 생략할 수 있으므로 이 검사는 보통 비용이 들지 않아요. 두 번째 인자도 계산된다면, 이 계산을 수행하는 외부 생성자 메서드를 제공하는 게 유리할 수 있어요.

ConstrainedType(A) = ConstrainedType(A, compute_B(A))

왜 Julia는 네이티브 머신 정수 산술을 사용하나요?

Julia는 정수 계산에 머신 산술을 사용합니다. 즉 Int 값의 범위가 제한되고 양끝에서 감싸서(wrap around), 정수의 덧셈·뺄셈·곱셈이 오버플로우되거나 언더플로우될 수 있고, 처음에는 불안하게 보일 수 있는 결과가 나타나요.

julia> x = typemax(Int)
9223372036854775807

julia> y = x+1
-9223372036854775808

julia> z = -y
-9223372036854775808

julia> 2*z
0

분명히 이는 수학적 정수가 동작하는 방식과는 아주 다르고, 고수준 프로그래밍 언어가 이런 걸 사용자에게 노출하는 건 이상적이지 않다고 생각할 수 있어요. 하지만 효율성과 투명성이 중요한 수치 작업에서는 대안이 더 나쁩니다.

고려할 대안 하나는 각 정수 연산이 오버플로우되었는지 검사하고, 오버플로우 시 결과를 Int128이나 BigInt 같은 더 큰 정수 타입으로 승격하는 거예요. 불행히도 이는 모든 정수 연산(루프 카운터 증가 같은)에 큰 오버헤드를 도입합니다. 산술 명령 뒤에 런타임 오버플로우 검사를 수행하고 잠재적 오버플로우를 처리하는 분기를 방출해야 하니까요. 더 심한 건, 이로 인해 정수를 포함한 모든 계산이 타입-불안정해진다는 겁니다. 아까 말했듯이 타입 안정성은 효율적인 코드 생성에 결정적이에요. 정수 연산의 결과가 정수일 거라고 믿을 수 없으면, C와 Fortran 컴파일러가 하는 것처럼 빠르고 단순한 코드를 만들 수 없어요.

타입 불안정성이 겉으로 드러나지 않는 이 접근의 변형은 IntBigInt를 단일 하이브리드 정수 타입으로 병합해서, 결과가 더 이상 머신 정수의 크기에 맞지 않을 때 내부 표현을 바꾸는 것입니다. 표면적으로는 Julia 코드 수준에서 타입 불안정성을 피하지만, 같은 어려움을 모두 이 하이브리드 정수 타입을 구현하는 C 코드에 떠넘겨 문제를 카펫 밑으로 숨길 뿐이에요. 이 접근은 동작하게 만들 수 있고 많은 경우 꽤 빠르게 만들 수도 있지만, 단점이 몇 가지 있습니다. 한 가지 문제는 정수와 정수 배열의 인메모리 표현이 더 이상 C, Fortran, 그리고 네이티브 머신 정수를 쓰는 다른 언어들이 사용하는 자연스러운 표현과 일치하지 않는다는 거예요. 따라서 그 언어들과 상호 운용하려면 결국 네이티브 정수 타입을 도입해야 합니다. 무제한 표현의 정수는 고정된 비트 수를 가질 수 없고, 따라서 고정 크기 슬롯의 배열에 인라인으로 저장될 수 없어요. 큰 정수 값은 항상 별도의 힙 할당 저장소가 필요합니다. 그리고 물론 어떤 하이브리드 정수 구현을 쓰든 항상 성능 함정, 즉 성능이 예기치 않게 저하되는 상황이 있습니다. 복잡한 표현, C·Fortran과의 상호 운용 부족, 추가 힙 저장소 없이 정수 배열을 표현할 수 없음, 예측 불가능한 성능 특성은 가장 영리한 하이브리드 정수 구현조차 고성능 수치 작업에는 나쁜 선택으로 만듭니다.

하이브리드 정수나 BigInt 승격 대신 쓸 대안은 포화 정수 산술(saturating integer arithmetic)인데, 가장 큰 정수 값에 더하면 그대로 두고, 가장 작은 정수 값에서 빼도 그대로 두는 방식이에요. 이것이 정확히 Matlab™이 하는 일입니다.

>> int64(9223372036854775807)

ans =

9223372036854775807

>> int64(9223372036854775807) + 1

ans =

9223372036854775807

>> int64(-9223372036854775808)

ans =

-9223372036854775808

>> int64(-9223372036854775808) - 1

ans =

-9223372036854775808

처음 보기에는 합리적으로 보일 수 있어요. 9223372036854775807이 -9223372036854775808보다 9223372036854775808에 훨씬 가깝고, 정수도 C·Fortran과 호환되는 자연스러운 방식으로 고정 크기로 표현되니까요. 하지만 포화 정수 산술은 깊은 문제가 있습니다. 가장 분명한 첫 번째 문제는 이게 머신 정수 산술이 동작하는 방식이 아니라는 거예요. 포화 연산을 구현하려면 각 머신 정수 연산 뒤에 언더플로우나 오버플로우를 검사하고 결과를 적절히 typemin(Int)이나 typemax(Int)로 대체하는 명령을 방출해야 합니다. 이는 각 정수 연산을 단일의 빠른 명령에서 몇 개의 명령으로 늘려요. 하지만 더 심해집니다. 포화 정수 산술은 결합법칙이 성립하지 않아요. 이 Matlab 계산을 고려해 보죠.

>> n = int64(2)^62
4611686018427387904

>> n + (n - 1)
9223372036854775807

>> (n + n) - 1
9223372036854775806

이 때문에 많은 기본 정수 알고리즘을 작성하기 어려워져요. 오버플로우가 있는 머신 덧셈이 결합법칙을 따른다는 사실에 의존하는 흔한 기법이 많으니까요. Julia에서 (lo + hi) >>> 1 표현식으로 정수 값 lohi 사이의 중간점을 찾는 경우를 생각해 보죠.

julia> n = 2^62
4611686018427387904

julia> (n + 2n) >>> 1
6917529027641081856

보이죠? 문제없어요. n + 2n이 -4611686018427387904임에도 불구하고, 이는 2^62와 2^63 사이의 올바른 중간점이에요. 이제 Matlab에서 시도해 보세요.

>> (n + 2*n)/2

ans =

4611686018427387904

어이쿠. Matlab에 >>> 연산자를 추가해도 도움이 안 돼요. n2n을 더할 때 일어나는 포화가 올바른 중간점을 계산하는 데 필요한 정보를 이미 파괴했으니까요.

결합법칙의 부재는 이런 기법에 의존할 수 없는 프로그래머에게 불행할 뿐만 아니라, 컴파일러가 정수 산술을 최적화하려고 할 수 있는 거의 모든 것을 무너뜨립니다. 예를 들어 Julia 정수는 보통 머신 정수 산술을 사용하므로, LLVM은 f(k) = 5k-1 같은 단순한 작은 함수를 공격적으로 최적화할 수 있어요. 이 함수의 머신 코드는 그냥 이겁니다.

julia> code_native(f, Tuple{Int})
.text
Filename: none
pushq %rbp
movq %rsp, %rbp
Source line: 1
leaq -1(%rdi,%rdi,4), %rax
popq %rbp
retq
nopl (%rax,%rax)

함수의 실제 본문은 정수 곱셈과 덧셈을 한 번에 계산하는 단일 leaq 명령이에요. f가 다른 함수에 인라인되면 이건 더 유리해져요.

julia> function g(k, n)
for i = 1:n
k = f(k)
end
return k
end
g (generic function with 1 methods)

julia> code_native(g, Tuple{Int,Int})
.text
Filename: none
pushq %rbp
movq %rsp, %rbp
Source line: 2
testq %rsi, %rsi
jle L26
nopl (%rax)
Source line: 3
L16:
leaq -1(%rdi,%rdi,4), %rdi
Source line: 2
decq %rsi
jne L16
Source line: 5
L26:
movq %rdi, %rax
popq %rbp
retq
nop

f 호출이 인라인되므로, 루프 본문은 결국 단일 leaq 명령이 됩니다. 이제 루프 반복 횟수를 고정하면 무슨 일이 일어나는지 보겠습니다.

julia> function g(k)
for i = 1:10
k = f(k)
end
return k
end
g (generic function with 2 methods)

julia> code_native(g,(Int,))
.text
Filename: none
pushq %rbp
movq %rsp, %rbp
Source line: 3
imulq $9765625, %rdi, %rax # imm = 0x9502F9
addq $-2441406, %rax # imm = 0xFFDABF42
Source line: 5
popq %rbp
retq
nopw %cs:(%rax,%rax)

컴파일러는 정수 덧셈과 곱셈이 결합법칙을 따르고 곱셈이 덧셈에 대해 분배된다는 것(둘 다 포화 산술에서는 성립하지 않음)을 알기 때문에, 전체 루프를 곱셈과 덧셈 하나로 최적화할 수 있어요. 포화 산술은 결합법칙과 분배법칙이 각 루프 반복에서 실패할 수 있어 실패가 어느 반복에서 일어나는지에 따라 결과가 달라지므로, 이런 종류의 최적화를 완전히 무너뜨립니다. 컴파일러는 루프를 풀 수는 있지만, 여러 연산을 더 적은 동등한 연산으로 대수적으로 축소할 수는 없어요.

정수 산술이 조용히 오버플로우되게 두는 것보다 가장 합리적인 대안은 어디서나 검사된 산술(checked arithmetic)을 하는 거예요. 덧셈, 뺄셈, 곱셈이 오버플로우되면 오류를 일으켜 값이 정확하지 않은 값을 만들어 내는 거죠. 이 블로그 포스트에서 Dan Luu는 이를 분석했는데, 이 접근이 이론적으로 가져야 할 사소한 비용이 아니라, 컴파일러(LLVM과 GCC)가 추가된 오버플로우 검사 주변을 우아하게 최적화하지 못해 상당한 비용이 든다는 걸 발견했어요. 미래에 이게 개선된다면 Julia에서 기본적으로 검사된 정수 산술을 쓸 것을 고려할 수 있겠지만, 지금은 오버플로우 가능성과 함께 살아가야 합니다.

그 사이에 오버플로우 안전 정수 연산은 SaferIntegers.jl 같은 외부 라이브러리를 통해 얻을 수 있습니다. 앞서 말했듯이 이런 라이브러리를 쓰면 검사된 정수 타입을 쓰는 코드의 실행 시간이 크게 늘어난다는 점을 기억하세요. 하지만 제한적으로 쓰면, 모든 정수 연산에 쓸 때보다는 훨씬 문제가 덜 합니다. 논의의 진행 상황은 여기에서 따라갈 수 있어요.

원격 실행 중 UndefVarError의 가능한 원인은 무엇인가요?

오류가 말하듯이, 원격 노드에서 UndefVarError의 즉각적인 원인은 그 이름을 가진 바인딩이 존재하지 않는다는 거예요. 가능한 원인 몇 가지를 살펴보겠습니다.

julia> module Foo
foo() = remotecall_fetch(x->x, 2, "Hello")
end

julia> Foo.foo()
ERROR: On worker 2:
UndefVarError: `Foo` not defined in `Main`
Stacktrace:
[...]

클로저 x->xFoo에 대한 참조를 갖고 있습니다. Foo가 노드 2에서 사용 불가하므로 UndefVarError가 던져져요.

Main이 아닌 다른 모듈 아래의 전역은 값으로 원격 노드에 직렬화되지 않습니다. 참조만 보내져요. (Main 아래를 제외하고) 전역 바인딩을 만드는 함수는 나중에 UndefVarError가 던져지게 할 수 있습니다.

julia> @everywhere module Foo
function foo()
global gvar = "Hello"
remotecall_fetch(()->gvar, 2)
end
end

julia> Foo.foo()
ERROR: On worker 2:
UndefVarError: `gvar` not defined in `Main.Foo`
Stacktrace:
[...]

위 예시에서 @everywhere module Foo는 모든 노드에 Foo를 정의했어요. 하지만 Foo.foo() 호출이 로컬 노드에 새 전역 바인딩 gvar를 만들었고, 이 바인딩이 노드 2에서 발견되지 않아 UndefVarError 오류가 났습니다.

이는 Main 모듈 아래 만들어진 전역에는 적용되지 않습니다. Main 모듈 아래의 전역은 직렬화되고, 원격 노드의 Main 아래에 새 바인딩이 만들어져요.

julia> gvar_self = "Node1"
"Node1"

julia> remotecall_fetch(()->gvar_self, 2)
"Node1"

julia> remotecall_fetch(varinfo, 2)
name size summary
––––––––– –––––––– –––––––
Base Module
Core Module
Main Module
gvar_self 13 bytes String

이것은 function이나 struct 선언에는 적용되지 않습니다. 하지만 전역 변수에 바인딩된 익명 함수는 아래에서 보듯 직렬화됩니다.

julia> bar() = 1
bar (generic function with 1 method)

julia> remotecall_fetch(bar, 2)
ERROR: On worker 2:
UndefVarError: `#bar` not defined in `Main`
[...]

julia> anon_bar = ()->1
(::#21) (generic function with 1 method)

julia> remotecall_fetch(anon_bar, 2)
1

"메서드가 매치되지 않음" 문제 해결: 파라메트릭 타입 불변성과 MethodErrors (Troubleshooting "method not matched")

foo(bar::Vector{Real}) = 42를 선언하고 foo([1])을 호출하면 왜 안 되나요?

직접 해 보면 알겠지만, 결과는 MethodError예요.

julia> foo(x::Vector{Real}) = 42
foo (generic function with 1 method)

julia> foo([1])
ERROR: MethodError: no method matching foo(::Vector{Int64})
The function `foo` exists, but no method is defined for this combination of argument types.

Closest candidates are:
foo(!Matched::Vector{Real})
@ Main none:1

Stacktrace:
[...]

이유는 Vector{Real}Vector{Int}의 슈퍼타입이 아니기 때문이에요! foo(bar::Vector{T}) where {T<:Real}(또는 정적 매개변수 T를 함수 본문에서 쓸 필요가 없다면 짧은 형태 foo(bar::Vector{<:Real})) 같은 것으로 이 문제를 해결할 수 있어요. T는 와일드 카드입니다. 먼저 Real의 서브타입이어야 한다고 지정하고, 그다음 함수가 그 타입의 원소를 가진 Vector를 받는다고 지정하는 거죠.

이 같은 문제는 Vector뿐 아니라 어떤 복합 타입 Comp에도 해당합니다. Comp가 타입 Y의 매개변수를 선언했다면, 타입 X<:Y의 매개변수를 가진 다른 타입 Comp2Comp의 서브타입이 아닙니다. 이는 타입 불변성(type-invariance)이에요(대조적으로 Tuple은 매개변수에 대해 타입 공변입니다). 자세한 설명은 파라메트릭 복합 타입을 참고하세요.

왜 Julia는 문자열 연결에 *를 쓰나요? 왜 +나 다른 게 아닌가요?

+에 반대하는 주된 논거는 문자열 연결이 교환적이지 않은데 +는 보통 교환적 연산자로 쓰인다는 거예요. Julia 커뮤니티는 다른 언어들이 다른 연산자를 쓰고 *가 일부 사용자에게 생소할 수 있다는 걸 인지하지만, *는 특정 대수적 성질을 전달합니다.

string(...)으로 문자열(그리고 문자열로 변환된 다른 값)을 연결할 수도 있다는 점을 기억하세요. 마찬가지로 repeat^ 대신 문자열 반복에 쓸 수 있어요. 보간 문법도 문자열을 만드는 데 유용합니다.

패키지와 모듈 (Packages and Modules)

"using"과 "import"의 차이는 무엇인가요?

usingimport 사이에는 여러 차이가 있습니다(모듈 절 참고), 하지만 얼핏 직관적이지 않게 보일 수 있고 표면적으로(문법적으로)는 아주 사소해 보이는 중요한 차이가 하나 있어요. using으로 모듈을 로드할 때는 모듈 Foo의 함수 bar를 새 메서드로 확장하려면 function Foo.bar(...이라고 써야 하지만, import Foo.bar로는 그냥 function bar(...이라고 쓰기만 하면 자동으로 모듈 Foo의 함수 bar를 확장합니다.

이 차이에 별도 문법이 주어질 만큼 중요한 이유는, 존재하는지도 몰랐던 함수를 실수로 확장하는 걸 원하지 않기 때문이에요. 그건 쉽게 버그를 만들 수 있으니까요. 이는 문자열이나 정수 같은 흔한 타입을 받는 메서드에서 가장 일어나기 쉬운데, 당신과 다른 모듈 둘 다 그런 흔한 타입을 다루는 메서드를 정의할 수 있기 때문이에요. import를 쓰면 다른 모듈의 bar(s::AbstractString) 구현을 당신의 새 구현으로 대체하게 되는데, 이는 완전히 다른 일을 하기 쉽고(모듈 Foo에서 bar 호출에 의존하는 다른 함수들의 현재·미래 사용을 깨뜨릴 수 있음) 말이죠.

무와 누락값 (Nothingness and missing values)

"null", "무", "누락"이 Julia에서 어떻게 동작하나요?

많은 언어(예: C와 Java)와 달리, Julia 객체는 기본적으로 "null"일 수 없어요. 참조(변수, 객체 필드, 배열 원소)가 초기화되지 않으면, 접근하는 즉시 오류가 던져집니다. 이 상황은 isdefined이나 isassigned 함수로 감지할 수 있어요.

어떤 함수는 부수 효과 때문에만 쓰이고 값을 반환할 필요가 없습니다. 이런 경우 관례는 nothing 값을 반환하는 거예요. nothing은 타입 Nothing의 싱글턴 객체일 뿐입니다. 필드가 없는 평범한 타입이고, 이 관례 외에는 특별한 게 없으며, REPL이 그것에 대해 아무것도 출력하지 않는 것만 빼면요. 그 외에는 값이 없는 언어 구조도 nothing을 내놓습니다. 예를 들어 if false; end처럼요.

타입 T의 값 x가 때때로만 존재하는 상황에서는 Union{T, Nothing} 타입을 함수 인자, 객체 필드, 배열 원소 타입으로 써서 다른 언어의 Nullable/Option/Maybe에 해당하는 걸 만들 수 있어요. 값 자체가 nothing일 수 있다면(특히 TAny일 때), Union{Some{T}, Nothing} 타입이 더 적절합니다. x == nothing이면 값의 부재를, x == Some(nothing)이면 nothing과 같은 값의 존재를 나타내니까요. something 함수는 Some 객체를 풀고 nothing 인자 대신 기본값을 쓸 수 있게 해 줍니다. 컴파일러가 Union{T, Nothing} 인자나 필드를 다룰 때 효율적인 코드를 생성할 수 있다는 점도 참고하세요.

통계적 의미의 누락 데이터(R의 NA나 SQL의 NULL)를 나타내려면 missing 객체를 사용하세요. 자세한 내용은 Missing Values 절을 참고하세요.

어떤 언어에서는 빈 튜플(())을 무의 정형(canonical form)으로 여깁니다. 하지만 julia에서는 이것을 그냥 값이 0개인 평범한 튜플로 생각하는 게 가장 좋아요.

Union{}(빈 유니온 타입)으로 쓰는 빈(또는 "바닥") 타입은 값도 서브타입도 없는(자기 자신을 제외한) 타입이에요. 보통 이 타입을 쓸 필요는 없습니다.

메모리 (Memory)

x와 y가 배열일 때 x += y는 왜 메모리를 할당하나요?

Julia에서 x += y는 lowering 중에 x = x + y로 대체됩니다. 배열의 경우 이는 결과를 x와 같은 메모리 위치에 저장하는 대신, 결과를 저장할 새 배열을 할당한다는 결과를 가져와요. x를 변형하고 싶다면 x .+= y를 써서 각 원소를 개별적으로 갱신하세요.

이 동작이 어떤 사람에게는 놀랄 수 있지만, 선택은 의도적입니다. 주된 이유는 Julia 안에 불변 객체가 존재해서, 일단 만들어진 값은 바꿀 수 없기 때문이에요. 실제로 숫자는 불변 객체입니다. x = 5; x += 1 문은 5의 의미를 바꾸지 않고 x에 바인딩된 값을 바꿔요. 불변 객체의 경우 값을 바꾸는 유일한 방법은 재할당하는 것입니다.

조금 더 부연하자면, 다음 함수를 고려해 보세요.

function power_by_squaring(x, n::Int)
ispow2(n) || error("This implementation only works for powers of 2")
while n >= 2
x *= x
n >>= 1
end
x
end

x = 5; y = power_by_squaring(x, 4) 같은 호출 후에는 기대한 결과를 얻을 겁니다. x == 5 && y == 625죠. 그런데 이제 *=가 행렬과 쓰일 때 왼쪽을 대신 변형한다고 가정해 보죠. 두 가지 문제가 생겨요.

  • 일반 정방행렬의 경우 A = A*B는 임시 저장소 없이는 구현할 수 없어요. A[1,1]이 오른쪽에서 사용을 끝내기 전에 왼쪽에 계산·저장되니까요.
  • 계산을 위해 임시 저장소를 할당하겠다고 가정하면(*=를 제자리에서 동작하게 만드는 요점의 대부분을 없애겠지만), x의 가변성을 이용한다면 이 함수는 가변 입력과 불변 입력에 대해 다르게 동작할 거예요. 특히 불변 x에 대해 호출 후 일반적으로 y != x가 되지만, 가변 x에 대해서는 y == x가 됩니다.
  • 제네릭 프로그래밍 지원이 다른 수단(예: 브로드캐스팅이나 명시적 루프)으로 얻을 수 있는 잠재적 성능 최적화보다 더 중요하다고 여겨지기 때문에, +=*= 같은 연산자는 새 값을 다시 바인딩하는 방식으로 동작해요.

비동기 IO와 동시 동기 쓰기 (Asynchronous IO and concurrent synchronous writes)

같은 스트림에 동시에 쓰면 왜 출력이 뒤섞이나요?

스트리밍 I/O API는 동기적이지만, 기반 구현은 완전히 비동기적이에요.

다음을 출력한 결과를 고려해 보죠.

julia> @sync for i in 1:3
Threads.@spawn write(stdout, string(i), " Foo ", " Bar ")
end
123 Foo Foo Foo Bar Bar Bar

이런 일이 일어나는 이유는 write 호출이 동기적이지만, 각 인자를 쓰는 동안 그 I/O 부분이 완료되기를 기다리며 다른 태스크에 양보(yield)하기 때문이에요.

printprintln은 호출 중에 스트림을 "잠급니다". 결과적으로 위 예시에서 writeprintln으로 바꾸면 다음과 같이 됩니다.

julia> @sync for i in 1:3
Threads.@spawn println(stdout, string(i), " Foo ", " Bar ")
end
1 Foo Bar
2 Foo Bar
3 Foo Bar

ReentrantLock으로 쓰기를 잠글 수도 있어요. 이렇게요.

julia> l = ReentrantLock();

julia> @sync for i in 1:3
Threads.@spawn begin
lock(l)
try
write(stdout, string(i), " Foo ", " Bar ")
finally
unlock(l)
end
end
end
1 Foo Bar 2 Foo Bar 3 Foo Bar

배열 (Arrays)

0차원 배열과 스칼라의 차이는 무엇인가요?

0차원 배열은 Array{T,0} 형태의 배열이에요. 스칼라와 비슷하게 동작하지만 중요한 차이가 있습니다. 배열의 제네릭 정의를 고려하면 논리적으로 말이 되는 특수한 경우라 특별히 언급할 가치가 있는데, 처음에는 좀 직관적이지 않을 수 있으니까요. 다음 줄은 0차원 배열을 정의합니다.

julia> A = zeros()
0-dimensional Array{Float64,0}:
0.0

이 예시에서 A는 한 원소를 담는 가변 컨테이너인데, A[] = 1.0으로 설정하고 A[]로 가져올 수 있어요. 모든 0차원 배열은 같은 크기(size(A) == ())와 같은 길이(length(A) == 1)를 가집니다. 특히 0차원 배열은 비어 있지 않아요. 이것이 직관적이지 않게 느껴진다면, Julia의 정의를 이해하는 데 도움이 될 아이디어가 여기 있습니다.

  • 0차원 배열은 벡터의 "선"과 행렬의 "평면"에 대한 "점"이에요. 선에 면적이 없지만(여전히 사물의 집합을 나타내지만) 점에 길이나 차원이 전혀 없듯이(그래도 사물 하나를 나타내지만) 말이죠.
  • 우리는 prod(())를 1로 정의하고, 배열의 총 원소 수는 크기의 곱이에요. 0차원 배열의 크기는 ()이므로 그 길이는 1입니다.
  • 0차원 배열은 네이티브로 인덱싱할 수 있는 차원이 없어요. 그냥 A[]일 뿐이죠. 다른 모든 배열 차원과 같은 "끝에 1 붙이기(trailing one)" 규칙을 적용할 수 있으므로, 실제로는 A[1], A[1,1] 등으로 인덱싱할 수 있어요. 생략 및 추가 인덱스를 참고하세요.
  • 평범한 스칼라와의 차이를 이해하는 것도 중요해요. 스칼라는 가변 컨테이너가 아닙니다(반복 가능하고 length, getindex 같은 것을 정의하지만요. 예: 1[] == 1). 특히 x = 0.0이 스칼라로 정의되면 x[] = 1.0으로 그 값을 바꾸려는 시도는 오류예요. 스칼라 xfill(x)로 그것을 담은 0차원 배열로 변환할 수 있고, 반대로 0차원 배열 aa[]로 담긴 스칼라로 변환할 수 있어요. 또 다른 차이는 스칼라는 2 * rand(2,2) 같은 선형대수 연산에 참여할 수 있지만, 0차원 배열을 쓴 이와 유사한 연산 fill(2) * rand(2,2)은 오류라는 거예요.

선형대수 연산을 위한 Julia 벤치마크가 왜 다른 언어와 다른가요?

다음 같은 선형대수 구성 요소의 단순한 벤치마크가

using BenchmarkTools
A = randn(1000, 1000)
B = randn(1000, 1000)
@btime $A \ $B
@btime $A * $B

Matlab이나 R 같은 다른 언어와 비교할 때 달라질 수 있다는 걸 발견할 수 있어요.

이런 연산은 관련 BLAS 함수에 대한 아주 얇은 래퍼이므로, 차이의 이유는 아주 확실하게

  • 각 언어가 쓰는 BLAS 라이브러리,
  • 동시 스레드의 수

입니다.

Julia는 자체 OpenBLAS 사본을 컴파일해 쓰고, 스레드는 현재 8(또는 당신 코어의 수)로 제한됩니다.

OpenBLAS 설정을 수정하거나 Julia를 다른 BLAS 라이브러리, 예를 들어 Intel MKL로 컴파일하면 성능이 개선될 수 있어요. Julia의 선형대수가 OpenBLAS 대신 Intel MKL BLAS와 LAPACK을 쓰게 하는 패키지인 MKL.jl을 쓸 수도 있고, 토론 포럼에서 수동으로 설정하는 방법에 대한 제안을 검색할 수도 있습니다. Intel MKL은 오픈소스가 아니므로 Julia에 번들될 수 없다는 점을 기억하세요.

컴퓨팅 클러스터 (Computing cluster)

분산 파일 시스템에서 프리컴파일 캐시를 어떻게 관리하나요?

공유 파일 시스템을 가진 고성능 컴퓨팅(HPC) 시설에서 Julia를 쓸 때는 공유 depot(JULIA_DEPOT_PATH 환경 변수로)을 쓰는 게 권장돼요. Julia v1.10부터, 기능적으로 유사한 워커에 있는 여러 Julia 프로세스가 같은 depot을 사용하면 pidfile 잠금을 통해 조정해서, 다른 프로세스는 기다리는 동안 한 프로세스에서만 프리컴파일 노력을 쏟도록 해요. 프리컴파일 과정은 프로세스가 프리컴파일 중이거나 다른 프로세스의 프리컴파일을 기다리고 있을 때를 알려 줍니다. 비인터랙티브면 메시지가 @debug로 나와요.

하지만 바이너리 코드 캐싱 때문에, v1.9부터 캐시 거부가 더 엄격해졌고 사용자는 HPC 환경 전체에서 쓸 수 있는 단일 캐시를 얻기 위해 JULIA_CPU_TARGET 환경 변수를 적절히 설정해야 할 수 있어요.

Julia 릴리스 (Julia Releases)

Stable, LTS, nightly 버전 중 어떤 걸 써야 하나요?

Julia의 Stable 버전은 가장 최근에 릴리스된 Julia 버전이고, 대부분의 사람이 실행하고 싶어 하는 버전이에요. 개선된 성능을 포함한 최신 기능을 갖고 있습니다. Julia의 Stable 버전은 SemVer에 따라 v1.x.y로 버전을 매겨요. 새 Stable 버전에 해당하는 새 마이너 릴리스는 릴리스 후보로 몇 주를 테스트한 뒤 대략 4-5개월마다 만들어집니다. LTS 버전과 달리 Stable 버전은 다른 Stable 버전이 릴리스된 후에는 보통 버그픽스를 받지 않아요. 하지만 각 Julia v1.x 릴리스가 이전 버전용으로 작성된 코드를 계속 실행하므로, 다음 Stable 릴리스로의 업그레이드는 항상 가능합니다.

매우 안정적인 코드 베이스를 찾고 있다면 Julia의 LTS(Long Term Support) 버전이 더 좋을 수 있어요. 현재 LTS Julia 버전은 SemVer에 따라 v1.6.x로 버전이 매겨지고, 이 브랜치는 새 LTS 브랜치가 선택될 때까지 버그픽스를 계속 받을 거예요. 그 시점에 v1.6.x 시리즈는 더 이상 정기 버그픽스를 받지 않고, 가장 보수적인 사용자를 제외한 모두가 새 LTS 버전 시리즈로 업그레이드하라고 권고받을 겁니다. 패키지 개발자로서는 패키지를 사용할 수 있는 사용자 수를 최대화하려고 LTS 버전을 위해 개발하고 싶을 수 있어요. SemVer에 따라 v1.0용으로 작성된 코드는 미래의 모든 LTS와 Stable 버전에서 계속 동작합니다. 일반적으로 LTS를 대상으로 삼더라도, 개선된 성능을 활용하기 위해 최신 Stable 버전에서 코드를 개발하고 실행할 수 있어요. 새 기능(추가된 라이브러리 함수나 새 메서드 같은)을 쓰는 것만 피하면 됩니다.

언어의 최신 업데이트를 활용하고 싶고, 오늘 사용 가능한 버전이 가끔 제대로 동작하지 않아도 괜찮다면 nightly 버전을 선호할 수 있어요. 이름이 뜻하듯이 nightly 버전은 대략 매일 밤 릴리스됩니다(빌드 인프라 안정성에 따라 다르지만). 일반적으로 nightly 릴리스는 꽤 쓰기 안전해요. 코드에 불이 붙는 일은 없으니까요. 하지만 가끔 회귀나 문제가 있을 수 있는데, 더 철저한 사전 릴리스 테스트를 거치기 전까지는 발견되지 않는 것들이에요. 릴리스가 만들어지기 전에 사용 사례에 영향을 주는 그런 회귀가 잡히는지 확인하려고 nightly 버전으로 테스트하는 게 좋을 수 있어요.

마지막으로, Julia를 직접 소스에서 빌드하는 것도 고려할 수 있어요. 이 옵션은 주로 명령줄에 익숙하거나 배우는 데 관심이 있는 개인을 위한 거예요. 그런 사람이라면 기여 지침도 읽는 데 관심이 있을 거예요.

juliaup 설치 관리자에는 최신 안정 릴리스와 현재 LTS 릴리스를 위한 releaselts라는 미리 정의된 채널이 있고, 버전별 채널도 있어요.

Julia 버전을 업데이트한 후 설치된 패키지 목록을 어떻게 옮기나요?

julia의 각 마이너 버전은 자기만의 기본 환경을 가집니다. 결과적으로 새 마이너 버전의 Julia를 설치하면, 이전 마이너 버전으로 추가한 패키지는 기본적으로 사용할 수 없어요. 주어진 julia 버전의 환경은 .julia/environments/에서 버전 번호와 일치하는 폴더 안의 Project.tomlManifest.toml 파일로 정의됩니다. 예를 들어 .julia/environments/v1.3처럼요.

새 마이너 버전의 Julia를 설치했다고 해 보죠. 예를 들어 1.4요. 이전 버전(예: 1.3)과 같은 패키지를 기본 환경에서 쓰고 싶다면, 1.3 폴더의 Project.toml 파일 내용을 1.4로 복사할 수 있습니다. 그런 다음 새 Julia 버전의 세션에서 ] 키를 입력해 "패키지 관리 모드"에 들어가 instantiate 명령을 실행하면 돼요.

이 연산은 복사된 파일에서 대상 Julia 버전과 호환되는 가능한 패키지 집합을 해석하고, 적절하면 설치하거나 업데이트합니다. 패키지 집합뿐 아니라 이전 Julia 버전에서 사용하던 버전까지 재현하고 싶다면, Pkg 명령 instantiate를 실행하기 전에 Manifest.toml 파일도 복사해야 해요. 하지만 패키지가 Julia 버전 변경의 영향을 받을 수 있는 호환성 제약을 정의할 수 있으므로, 1.3에서 갖고 있던 정확한 버전 집합이 1.4에서 동작하지 않을 수도 있다는 점을 기억하세요.

더 알아보기 (Learn more)