23 July 2019 | Jeff Bezanson (Julia Computing), Jameson Nash (Julia Computing), Kiran Pamnany (Intel)
역자주
2019년 blog 의 번역
2019 년 글이므로 현재와 많이 다를 수 있음.
2022.12.27
소프트웨어 성능은 다수의 프로세서 코어를 활용하는 데 점점 더 많이 의존합니다. 무어의 법칙으로부터의 공짜 점심은 여전히 종료상태입니다. 뭐, 여기 Julia 개발자 커뮤니티의 우리들은 성능에 신경을 쓰는 것으로 유명합니다. 성능을 추구하면서 우리는 이미 다중 프로세스, 분산 및 GPU를 위한 많은 기능을 구축했지만 구성 가능한(composabile) 다중 스레딩에 대한 좋은 이야기도 필요하다는 것을 수년 동안 알고 있었습니다. 오늘 우리는 그 이야기의 중요한 새 장을 발표하게 되어 기쁩니다. Cilk, Intel Threading Building Blocks 및 Go와 같은 병렬 프로그래밍 시스템에서 영감을 받은 Julia 프로그램용의 완전히 새로운 스레딩 인터페이스인 일반 작업 병렬화의 시험판을 공개합니다. 작업 병렬 처리는 이제 v1.3.0-alpha 릴리스에서 사용할 수 있으며 Julia 버전 1.3.0의 초기 시험판은 몇 달 안에 공개될 것입니다. 다운로드 페이지에서 이 기능이 있는 바이너리를 찾거나 소스에서 마스터 브랜치를 빌드할 수 있습니다.
이 패러다임에서는 프로그램의 모든 부분을 병렬로 실행하도록 표시할 수 있으며 task가 시작되어 사용 가능한 스레드에서 해당 코드를 자동으로 실행합니다. 동적 스케줄러가 모든 결정과 세부 사항을 처리합니다. 이제 Julia에서 작성할 수 있는 병렬 코드의 예는 다음과 같습니다.
import Base.Threads.@spawn
function fib(n::Int)
if n < 2
return n
end
t = @spawn fib(n - 2)
return fib(n - 1) + fetch(t)
end
이것은 피보나치 수열에 대한 고전적이며 매우 비효율적인 트리 재귀 구현이지만 여러 프로세서 코어에서 실행됩니다! t = @spawn fib(n - 2)
는 fib(n - 2)
를 계산하는 작업을 시작하며, 이는 fib(n - 1)
을 계산하는 다음 라인과 병렬로 실행됩니다. fetch(t)
는 작업 t
가 완료될 때까지 기다렸다가 반환 값을 가져옵니다.
이 병렬 처리 모델에는 많은 놀라운 속성이 있습니다. 우리는 이를 가비지 수집과 어느 정도 유사하다고 생각합니다. GC를 사용하면 객체가 해제되는 시기와 방법에 대해 걱정하지 않고 객체를 자유롭게 할당할 수 있습니다. task 병렬 처리를 사용하면 작업이 실행되는 위치에 대해 걱정할 필요 없이 잠재적으로 수백만 개의 task를 자유롭게 생성(spawn)할 수 있습니다.
이 모델은 이식성이 좋고(portable) 하고 저수준의 상세사항으로부터 자유롭습니다. 스레드를 명시적으로 시작하고 중지할 필요가 없으며 프로세서 또는 스레드가 몇 개인지 알 필요도 없습니다(원한다면 찾을 수 있습니다).
이 모델은 중첩 가능하고 구성 가능합니다. 자체적으로 병렬 task 들을 시작하는 라이브러리 함수를 호출하는 병렬 tasks 를 시작할 수 있으며, 모든 것이 잘 작동합니다. CPU는 스레드로 초과 구독되지 않습니다(Your CPUs will not be over-subscribed with threads). 이 속성은 많은 task 들이 라이브러리 함수에 의해 수행되는 고급 언어에 매우 중요합니다. 호출하는 라이브러리가 구현되는 방식에 대해 걱정하지 않고 병렬 코드를 포함하여 필요한 모든 코드를 자유롭게 작성할 수 있어야 합니다(현재 Julia 코드에만 해당되지만 향후 BLAS와 같은 기본 라이브러리로 확장할 계획입니다).
사실 이것이 우리가 이 발표에 대해 흥분하는 주된 이유입니다. 이 시점부터 멀티 코어 병렬 처리 기능을 추가하는 기능이 전체 Julia 패키지 생태계에 공개됩니다.
이 새로운 기능의 가장 놀라운 측면 중 하나는 이것이 매우 오랜 시간이 걸렸다는 것입니다. 0.1 릴리스 이전부터 줄리아에는 이벤트 기반 I/O에 사용되는 대칭 코루틴(symmetric coroutine) 을 제공하는 Task
타입이 존재했습니다. 그래서 우리는 항상 언어에 동시성 단위(독립적인 스트림 실행)를 가지고 있었지만 아직 병렬(동시 스트림 실행)이 아니었습니다. 우리는 스레드 기반 병렬 처리가 필요하다는 것을 알고 있었기 때문에 2014년(대략 버전 0.3 기간)에 코드를 스레드로부터 안전하게 만드는 긴 과정에 착수했습니다. Yichao Yu는 가비지 컬렉터와 스레드-로컬-스토리지 성능에 대해 특히 인상적인 작업을 했습니다. 저자 중 한 명(Kiran)은 다중 스레드 스케쥴링과 원자적 데이터 구조 관리를 위한 몇 가지 기본 인프라스트력쳐를 설계했습니다.
약 2년 후 0.5 버젼에서 모든 코어에서 실행되는 간단한 병렬 루프를 처리할 수 있는 "실험적" 단계의 @threads for
매크로를 공개했습니다. 그것이 우리가 원하는 최종 디자인은 아니었지만 두 가지 중요한 작업을 수행했습니다. Julia 프로그래머가 여러 코어를 활용할 수 있게 했고 런타임에서 스레드 관련 버그를 제거하기 위한 테스트 케이스를 제공했습니다. 그러나 초기 @threads
에는 몇 가지 큰 제한이 있었습니다. @threads
루프는 중첩될 수 없었습니다. 호출한 함수가 @threads
를 재귀적으로 사용했다면 해당 내부 루프는 호출한 CPU만 차지하게 됩니다. 또한 Task
및 I/O 시스템과 호환되지 않았습니다. I/O를 수행하거나 스레드 루프 내에서 Task
사이의 전환은 불가능했습니다.
따라서 논리적으로 다음 단계는 Task
와 스레딩 시스템을 병합하고 Task
가 스레드 풀에서 동시에 실행되도록 "간단히"(정말??) 허용하는 것이었습니다. 우리는 초기에 Arch Robison(당시 인텔)과 많은 논의를 했고 이것이 우리 언어에 가장 적합한 모델이라는 결론을 내렸습니다. 버전 0.5(2016년경) 이후 Kiran은 깊이 우선 스케줄링(depth-first scheduling) 아이디어를 기반으로 하는 새로운 병렬 작업 스케줄러 partr로 실험을 시작했습니다. 그는 멋진 애니메이션 슬라이드로 우리 모두를 납득시켰고, 그가 일부 작업을 기꺼이 수행하는 것도 나쁘지 않았습니다. 계획은 먼저 독립 실행형 C 라이브러리로 partr를 개발하여 자체적으로 테스트하고 벤치마킹한 다음 Julia 런타임과 통합하는 것이었습니다.
Kiran이 partr의 독립 실행형 버전을 완성한 후 우리 중 일부(이 게시물의 작성자, Keno Fischer 및 Intel의 Anton Malakhov)는 통합을 수행하는 방법을 파악하기 위해 일련의 대면 작업 세션에 착수했습니다. Julia 런타임은 가비지 수집 및 이벤트 기반 I/O와 같은 많은 추가 기능을 제공하므로 완전히 간단하지는 않았습니다. 다소 실망스러운 것은 복잡한 소프트웨어 프로젝트의 경우 드문 일이 아니지만 새 시스템이 안정적으로 작동하는 데 예상보다 훨씬 더 오랜 시간(거의 2년)이 걸렸다는 것입니다. 이 게시물의 뒷부분에서 호기심에 관련된 일부 내부 및 어려움에 대해 설명합니다. 하지만 먼저 한 번 살펴보겠습니다.
줄리아를 다중 스레드로 사용하기 위해서는 JULIA_NUM_THREADS
환경변수를 설정해야 합니다.
$ JULIA_NUM_THREADS=4 ./julia
Juno IDE 는 사용가능한 프로세서의 코어 갯수에 기반하여 스레드 개수를 자동적으로 설정하며, 스레드 개수를 변경하기 위한 그래픽 인터페이스를 제공하기때문에 Juno IDE 에서는 수동으로 이 변수를 설정할 필요가 없습니다.
Base
의 Threads
하위 모듈에는 스레드 수 및 현재 스레드의 ID 쿼리와 같은 대부분의 스레드 관련 기능이 있습니다.
julia> Threads.nthreads()
4
julia> Threads.threadid()
1
기존의 @threads for
사용법은 여전히 작동하며, 이제는 I/O 가 전적으로 지원됩니다.
julia> Threads.@threads for i = 1:10
println("i = $i on thread $(Threads.threadid())")
end
i = 1 on thread 1
i = 7 on thread 3
i = 2 on thread 1
i = 8 on thread 3
i = 3 on thread 1
i = 9 on thread 4
i = 10 on thread 4
i = 4 on thread 2
i = 5 on thread 2
i = 6 on thread 2
더 이상 고민하지 않고 중첩 병렬 처리를 시도해 봅시다. 이런 시도에 전형적인 예시는 입력을 반으로 나누고 각 반을 재귀적으로 정렬하는 mergesort입니다. 절반은 독립적으로 정렬할 수 있으므로 자연스럽게 병렬화할 수 있습니다. 다음은 해당 코드입니다.
import Base.Threads.@spawn
# sort the elements of `v` in place, from indices `lo` to `hi` inclusive
function psort!(v, lo::Int=1, hi::Int=length(v))
if lo >= hi # 1 or 0 elements; nothing to do
return v
end
if hi - lo < 100000 # below some cutoff, run in serial
sort!(view(v, lo:hi), alg = MergeSort)
return v
end
mid = (lo+hi)>>>1 # find the midpoint
half = @spawn psort!(v, lo, mid) # task to sort the lower half; will run
psort!(v, mid+1, hi) # in parallel with the current call sorting
# the upper half
wait(half) # wait for the lower half to finish
temp = v[lo:mid] # workspace for merging
i, k, j = 1, lo, mid+1 # merge the two sorted sub-arrays
@inbounds while k < j <= hi
if v[j] < temp[i]
v[k] = v[j]
j += 1
else
v[k] = temp[i]
i += 1
end
k += 1
end
@inbounds while k < j
v[k] = temp[i]
k += 1
i += 1
end
return v
end
이것은 표준 병합 정렬의 구현으로, Julia의 기본 라이브러리에 있는 것과 유사하며, 여기의 재귀 호출 중 하나에 @spawn
구문을 아주 조금 추가했습니다. Julia의 분산(Distributed
) 표준 라이브러리도 한동안 @spawn
매크로를 내보냈지만, 우리는 새로운 스레드화의 취지를 위해 중단할 계획입니다(역호환성을 위해 1.x 버전에서도 계속 사용할 수 있음). 병렬성을 표현하는 이런 방법은 공유 메모리에서 훨씬 더 유용하며 "spawn"은 task 병렬 API(예를 들어 Cilk 및 TBB에서 사용됨)에서 상당히 표준적인 용어입니다.
wait
는 단순히 지정된 작업이 완료될 때까지 기다립니다. 코드는 입력을 수정하여 작동하므로 작업의 반환 값이 필요하지 않습니다. 반환 값이 필요하지 않다는 것을 나타내는 것은 이전 fib
예제에서 사용된 fetch
호출과의 유일한 차이점입니다. Julia의 표준 sort!
을 호출할 때 명시적으로 MergeSort
를 요청하여 사과와 사과를 비교하고 있는지 확인하십시오 — sort!
는 실제로 숫자를 정렬하기 위해 기본적으로 quicksort 를 사용하는데, 이는 무작위 데이터에 대해 더 빠른 경향이 있습니다. JULIA_NUM_THREADS=2
에서 코드 시간을 측정해 보겠습니다.
julia> a = rand(20000000);
julia> b = copy(a); @time sort!(b, alg = MergeSort); # single-threaded
2.589243 seconds (11 allocations: 76.294 MiB, 0.17% gc time)
julia> b = copy(a); @time sort!(b, alg = MergeSort);
2.582697 seconds (11 allocations: 76.294 MiB, 2.25% gc time)
julia> b = copy(a); @time psort!(b); # two threads
1.770902 seconds (3.78 k allocations: 686.935 MiB, 4.25% gc time)
julia> b = copy(a); @time psort!(b);
1.741141 seconds (3.78 k allocations: 686.935 MiB, 4.16% gc time)
실행 시간은 다소 가변적이지만, 두 개의 스레드를 사용하면 확실한 속도 향상을 볼 수 있습니다. 우리가 이것을 실행한 노트북에는 4개의 하이퍼스레드가 있으며 특히 세 번째 스레드를 추가하면 성능이 계속 향상된다는 것이 놀랍습니다.
julia> b = copy(a); @time psort!(b);
1.511860 seconds (3.77 k allocations: 686.935 MiB, 6.45% gc time)
세 개의 스레드에서 실행되는 이 양방향 분해 알고리즘에 대해 생각하면 머리가 약간 아플 수 있습니다! 우리의 관점에서, 이것은 이 인터페이스가 병렬 처리를 어떻게 "자동화 되었다고" 느끼게 하는지를 강조하는 데 도움이 됩니다.
단일 스레드 성능은 약간 낮지만 CPU 코어는 더 많은 다른 머신을 사용해 봅시다.
$ for n in 1 2 4 8 16; do JULIA_NUM_THREADS=$n ./julia psort.jl; done
2.949212 seconds (3.58 k allocations: 686.932 MiB, 4.70% gc time)
1.861985 seconds (3.77 k allocations: 686.935 MiB, 9.32% gc time)
1.112285 seconds (3.78 k allocations: 686.935 MiB, 4.45% gc time)
0.787816 seconds (3.80 k allocations: 686.935 MiB, 2.08% gc time)
0.655762 seconds (3.79 k allocations: 686.935 MiB, 4.62% gc time)
psort.jl
스크립트는 단순히 psort!
를 정의합니다. 컴파일 오버헤드까지 포함혀여 측정되는것을 피하기 위해 한 번 호출한 다음 위에서 사용한 것과 동일한 명령을 실행합니다.
표준 루틴보다 훨씬 더 많은 메모리를 할당하는 병렬 코드임에도 불구하고 속도가 빨라집니다. 할당은 Task
개체와 각 호출에 할당된 temp
배열에서 발생합니다. The reference sorting routine 은 모든 재귀 호출 간에 단일 임시 버퍼를 재사용합니다. 임시 배열을 재사용하는 것은 병렬 처리에서 더 어렵지만 여전히 가능합니다. 이에 대해서는 나중에 자세히 설명합니다.
1.3 시리즈 동안 새 스레드 런타임은 베타 테스트 중인 것으로 간주됩니다. 장기적으로 사용할 수 있는 API를 결정할 시간을 주기 위해 "공식" 버전이 이후 릴리스에 나타날 것입니다. 이 기간 동안 코드를 업그레이드하려는 경우 알아야 할 사항은 다음과 같습니다.
호환성을 돕기 위해 코드는 기본적으로 단일 스레드 내에서 계속 실행됩니다. 기존 프리미티브(schedule
, @async
)를 사용하여 작업을 시작하면 작업을 시작하는 스레드 내에서만 실행됩니다. 마찬가지로 Condition
객체(이벤트가 발생할 때 작업에 신호를 보내는 데 사용됨)는 이를 만든 스레드에서만 사용할 수 있습니다. 다른 스레드에서 조건을 기다리거나 알리려고 하면 오류가 발생합니다. 별도의 스레드로부터 안전한 조건 변수가 추가되었으며 Threads.Condition
으로 사용할 수 있습니다. condition 변수를 스레드로부터 안전하게 사용하려면 잠금(lock)을 획득해야 하므로 별도의 타입이어야 합니다. Julia에서 잠금(lock)은 condition 과 함께 번들로 제공되므로 잠금(lock)은 condition 자체에서 간단히 호출할 수 있습니다.
lock(cond::Threads.Condition)
try
while !ready
wait(cond)
end
finally
unlock(cond)
end
이전 버전에서와 마찬가지로 중요한 섹션을 보호하는 데 사용할 표준 lock은 이제는 스레드로부터 안전한 ReentrantLock
입니다(이전에는 task 동기화에만 사용됨). 대부분 내부 목적으로 정의된 다른 유형의 lock(Threads.SpinLock
및 Threads.Mutex
)이 있습니다. 이들은 (1) task 가 아닌 스레드만 동기화되고 (2) lock 이 짧은 시간 동안만 유지된다는 것을 알고 있는 드문 상황에서 사용됩니다.
Threads
모듈은 표준 정의가 있는 Semaphore
및 Event
타입도 제공합니다.
Julia 코드는 태생적으로 순전히 기능적이거나(부작용이나 변형이 없는) 로컬 변형만 사용하는 경향이 있으므로 전체 스레드 안전성으로 마이그레이션하는 것이 많은 경우에 쉬울 것입니다. 그러나 코드에서 공유 상태를 사용하고 이를 스레드로부터 안전하게 만들고 싶다면 몇 가지 작업을 수행해야 합니다. 지금까지 우리는 Julia의 표준 라이브러리에서 synchronizaion(lock) 및 스레드-local 상태 또는 task-local 상태라는 두 가지 접근 방식을 사용했습니다. lock 은 자주 액세스하지 않는 공유 리소스 또는 각 스레드에 대해 복제할 수 없는 리소스에 대해 잘 작동합니다.
그러나 고성능 코드의 경우 스레드 로컬 상태를 권장합니다. 우리의 psort!
루틴은 이러한 방식으로 개선될 수 있습니다. 여기 레시피가 있습니다. 먼저 호출자가 공간을 제공하지 않을 때 자동으로 공간을 할당하는 기본 인수 값을 사용하여 사전 할당된 버퍼를 허용하도록 함수 서명을 수정합니다.
function psort!(v, lo::Int=1, hi::Int=length(v), temps=[similar(v, 0) for i = 1:Threads.nthreads()])
스레드당 초기에 비어 있는 하나의 배열을 할당하기만 하면 됩니다. 다음으로 재귀 호출을 수정하여 공간을 재사용합니다.
half = @spawn psort!(v, lo, mid, temps)
psort!(v, mid+1, hi, temps)
마지막으로 새 배열을 할당하는 대신 현재 스레드용으로 예약된 배열을 사용하고 필요에 따라 크기를 조정합니다.
temp = temps[Threads.threadid()]
length(temp) < mid-lo+1 && resize!(temp, mid-lo+1)
copyto!(temp, 1, v, lo, mid-lo+1)
이러한 경미한 수정 후에 대형 컴퓨터에서 성능을 확인하겠습니다.
$ for n in 1 2 4 8 16; do JULIA_NUM_THREADS=$n ./julia psort.jl; done
2.813555 seconds (3.08 k allocations: 153.448 MiB, 1.44% gc time)
1.731088 seconds (3.28 k allocations: 192.195 MiB, 0.37% gc time)
1.028344 seconds (3.30 k allocations: 221.997 MiB, 0.37% gc time)
0.750888 seconds (3.31 k allocations: 267.298 MiB, 0.54% gc time)
0.620054 seconds (3.38 k allocations: 298.295 MiB, 0.77% gc time)
Julia의 기본 전역 난수 생성기(rand()
및 그 친구들) 에서 우리가 취한 접근 방식은 그것을 스레드별로 만드는 것입니다. 처음 사용할 때 각 스레드는 시스템 엔트로피에서 시드된 기본 RNG 유형(현재 MersenneTwister
)의 독립 인스턴스를 생성합니다. 난수 상태(rand
, randn
, seed!
등)에 영향을 미치는 모든 작업은 현재 스레드의 RNG 상태에서만 작동합니다. 이렇게 하면 난수를 시드한 다음 사용하는 여러 개의 독립적인 코드 시퀀스가 예상대로 개별적으로 작동합니다.
알려진 초기 시드를 사용하기 위해 모든 스레드가 필요한 경우 명시적으로 설정해야 합니다. 이러한 종류의 보다 정확한 제어 또는 더 나은 성능을 위해 자체 RNG 개체(예: Random.MersenneTwister()
)를 할당하고 전달하는 것이 좋습니다.
가비지 수집과 마찬가지로 간단한 인터페이스(@spawn
)는 매우 복잡합니다. 여기서 우리는 우리가 직면한 몇 가지 주요 어려움과 설계적 결정사항들을 요약하고자 합니다.
각 Task
에는 Unix 운영 체제에서 제공하는 일반적인 프로세스 또는 스레드 스택과 다른 자체 실행 스택이 필요합니다. Windows에는 task 와 밀접하게 일치하는 fibers 가 있으며, 유사한 추상화의 여러 라이브러리 구현이 Unix 계열 시스템에 존재합니다.
서로 다른 장단점이 있는 많은 가능한 접근 방식이 있습니다. 자주 그렇듯이 처리량과 안정성을 최대화하는 방법을 선택하려고 했습니다. 우리는 mmap
(Windows의 VirtualAlloc
)에 의해 할당된 스택의 공유 풀을 가지고 있으며, 기본값은 각각 4MiB(32비트 시스템의 경우 2MiB)입니다. 이것은 꽤 많은 가상 메모리를 사용할 수 있으므로 top
을 실행시켰을 때 100GiB의 주소 공간을 사용하는 멋진 새 멀티 스레드 Julia 코드가 표시되더라도 놀라지 마십시오. 이 공간의 대부분은 실제 리소스를 소비하지 않으며 작업이 깊은 호출 체인을 실행해야 하는 경우에만 존재합니다(오래 지속되지 않기를 바랍니다). 이는 하위 수준 언어의 작업 시스템이 제공하는 것보다 더 큰 스택이지만 CPU 및 OS 커널의 고도로 정제된 메모리 관리 기능을 잘 활용하는 동시에 스택 오버플로 가능성을 크게 줄인다고 생각합니다.
기본 스택 크기는 src/options.h
에 설정된 빌드 시간 옵션입니다. Task
생성자에는 문서화되지 않은 두 번째 인수가 있어 task 당 스택 크기를 지정할 수 있습니다. 그러나 이것을 사용하는것을 추천하지 않습니다. 예를 들어 컴파일러 또는 호출된 라이브러리에 의해 얼마나 많은 스택 공간이 필요할지 예측하기 어렵기 때문입니다.
A thread can switch to running a given task just by adjusting its registers to appear to "return from" the previous switch away from that task. 우리는 실행을 시작하기 직전에 로컬 풀에서 새 스택을 할당합니다. task가 실행되는 즉시 과도한 GC 압력을 피하면서 해당 스택을 다시 pool 로 즉시 해제할 수 있습니다.
또한 task 전환이 발생할 때 라이브 스택 데이터를 복사하여 메모리와 시간을 교환하는 스택 전환(options.h
의 ALWAYS_COPY_STACKS
변수에 의해 제어됨)의 대체 구현도 있습니다. 이는 cfunction
을 사용하는 외부 코드와 호환되지 않을 수 있으므로 기본값이 아닙니다.
스택이 너무 많은 주소 공간을 사용하는 경우(일부 플랫폼, 특히 Linux 및 32비트 시스템은 상당히 낮은 제한을 부과함) 이 구현으로 돌아갑니다. 물론 각 구현에는 여러 플랫폼 및 아키텍처에 대한 코드가 있으며 때로는 인라인 어셈블리로 추가로 최적화됩니다. 스택 전환은 자체적으로 블로그 게시물을 채울 수 있는 풍부한 주제입니다.
크로스 플랫폼 이벤트 기반 I/O에 libuv를 사용합니다. 멀티스레드 프로그램 내에서 작동할 수 있도록 설계되었지만 명시적으로 멀티스레드 I/O 라이브러리가 아니므로 기본적으로 여러 스레드에서 동시 사용을 지원하지 않습니다. 지금은 단일 전역 lock 으로 libuv 구조에 대한 액세스를 보호한 다음 모든 스레드(한 번에 하나씩)가 이벤트 루프를 실행하도록 허용합니다. 다른 스레드가 깨어나기 위해 이벤트 루프 스레드가 필요한 경우 비동기 신호를 발행합니다. 이는 새 task를 예약하는 다른 스레드, 가비지 수집을 실행하기 시작하는 다른 스레드 또는 IO를 수행하기 위해 IO 잠금을 사용하려는 다른 스레드를 포함하여 여러 가지 이유로 발생할 수 있습니다.
일반적으로 task는 한 스레드에서 실행되기 시작하고 잠시 동안 차단된 다음 다른 스레드에서 다시 시작될 수 있습니다. 이는 스레드 로컬 값이 변경될 수 있는 시기에 대한 기본적인 가정을 변경합니다. 내부적으로 Julia 코드는 예를 들어 메모리를 할당할 때마다 스레드 로컬 변수를 지속적으로 사용합니다. 우리는 아직 마이그레이션을 지원하는 데 필요한 모든 변경을 시작하지 않았으므로 현재 작업은 항상 실행을 시작한 스레드에서 실행해야 합니다(물론 모든 스레드에서 실행을 시작할 수 있음). 이를 지원하기 위해 지정된 스레드에서 실행해야 하는 "고정된"(sticky) task 와 각 스레드와 관련된 task 를 실행하기 위한 스레드별 대기열의 개념이 있습니다.
모든 스레드를 계속 사용하기에 task 가 충분하지 않은 경우 일부는 항상 모든 CPU를 100% 사용하지 않도록 절전 모드로 전환해야 합니다. 일부 스레드가 새 작업을 예약하는 동안 다른 스레드가 절전 모드로 전환될 수 있기 때문에 이것은 까다로운 동기화 문제입니다.
task 가 차단되면 스케줄러를 호출하여 실행할 다른 작업을 선택해야 합니다. 해당 코드는 실행하는 데 어떤 스택을 사용합니까? 전용 스케줄러 작업을 가질 수 있지만 최근에 차단된 작업의 컨텍스트에서 스케줄러 코드를 실행하도록 허용하면 오버헤드가 줄어들 것이라고 생각했습니다. 그것은 잘 작동하지만 작업이 실행되고 있지 않은 것으로 간주되지만 실제로는 스케줄러를 실행하는 이상한 중간 상태에 존재할 수 있음을 의미합니다. 그것의 한 가지 의미는 우리가 전혀 전환할 필요가 없다는 것을 깨닫기 위해 스케줄러 대기열에서 작업을 가져올 수 있다는 것입니다.
이 새로운 기능을 작동시키려고 시도하는 동안 우리는 몇 가지 엄청나게 어려운 버그에 직면했습니다. 확실히 가장 좋아하는 것은 문자 그대로 단일 비트를 뒤집음으로써 수정된 Windows의 신비한 중단이었습니다.
또 다른 좋은 점은 누락된 예외 처리 성격이었습니다. 돌이켜보면 간단할 수도 있었지만 두 가지 요인으로 인해 혼란스러웠습니다. 첫 번째는 오류 모드로 인해 커널이 디버거에서 가로챌 수 없는 방식으로 프로세스를 중지시켰고 두 번째는 오류가 관련이 없어 보이는 변화에 의해 트리거 되는 것이었습니다. 모든 Julia 스택 프레임에는 예외 처리 특성 세트가 있으므로 문제는 Julia 프레임 외부의 런타임 시스템에서만 나타날 수 있습니다. 물론 우리는 일반적으로 Julia 코드를 실행하기 때문에 좁은 창입니다.
우리는 이 중요한 순간에 흥분하고 있긴 하지만 많은 작업이 남아 있습니다. 이 알파 릴리스는 @spawn
구조를 도입했지만 디자인을 마무리하기 위한 것은 아닙니다. 다음은 스레딩 기능을 더욱 발전시키기 위해 집중하고자 하는 몇 가지 사항입니다.
이러한 새로운 기능을 개발할 수 있도록 지원한 인텔과 rationalAI 의 자금 지원에 감사드립니다.
또한 이 기능이 개발되는 동안 끈기 있게 시도한 분들, 버그 보고서 또는 pull requests 를 제출하고 계속 진행할 수 있도록 자극해 준 여러 분들께 감사합니다. Julia에서 스레드를 사용하는 데 문제가 발생하면 GitHub 또는 Discourse 포럼에서 알려주세요!