복제
먼저, 몇 가지 짚고 넘어가겠습니다:
-
논의를 위해 가능한 한 가장 넓은 복제의 정의를 사용할 것입니다. 러스트의 정의는 아마 변형이나 살아있음 여부에 대해서는 조금 더 제한적일 것입니다.
-
우리는 한 스레드에서, 인터럽트 없는 실행을 가정하겠습니다. 또한 메모리-매핑된 하드웨어 같은 것들은 무시하겠습니다. 러스트는 당신이 말하지 않는 한 이런 것들이 일어나지 않는다고 가정합니다. 더 자세한 내용은 동시성 챕터를 참고하세요.
이런 것들을 말했으니, 이제 우리의 아직 작업중인 정의를 말해보겠습니다: 변수들과 포인터들은 메모리의 겹쳐지는 지역을 가리킬 때 복제되었다고 합니다.
복제가 중요한 이유
그래서 왜 우리가 복제를 신경써야 할까요?
이런 간단한 함수를 생각해 보세요:
#![allow(unused)] fn main() { fn compute(input: &u32, output: &mut u32) { if *input > 10 { *output = 1; } if *input > 5 { *output *= 2; } // `input > 10`일 경우 `output`은 `2`가 될 것이라는 것을 기억하세요 } }
이 함수를 다음의 함수로 최적화할 수 있다면 좋겠네요:
#![allow(unused)] fn main() { fn compute(input: &u32, output: &mut u32) { let cached_input = *input; // `*input`을 레지스터에 저장합니다. if cached_input > 10 { // `input`이 10보다 크면, 이전 코드는 `output`을 1로 지정했다가 2를 곱하니, // `output`은 최종적으로 2가 됩니다 (`>10`이면 `>5`일 테니까요). // 여기서는 두번 할당하는 것을 피하고 한번에 2로 설정합니다. *output = 2; } else if cached_input > 5 { *output *= 2; } } }
러스트에서는 이런 최적화가 건전할 것입니다. 거의 모든 다른 언어에서는 그렇지 않을 것입니다 (전역 분석을 제외하면). 이것은 이 최적화가 복제가 일어나지 않는다는 것에 의존하기 때문인데, 많은 언어들이 이것에 있어서 자유롭게 풀어두죠.
특별히 우리는 input
과 output
이 겹치는 함수 매개변수들, 예를 들면 compute(&x, &mut x)
같은 것들을 걱정해야 합니다.
이런 입력으로는 이런 실행이 가능합니다:
// input == output == 0xabad1dea
// *input == *output == 20
if *input > 10 { // 참 (*input == 20)
*output = 1; // *input 에도 씀, *output 과 같기 때문
}
if *input > 5 { // 거짓 (*input == 1)
*output *= 2;
}
// *input == *output == 1
우리의 최적화된 함수는 이런 입력에 *output == 2
라는 결과를 도출할 것이고, 따라서 우리의 최적화가 올바른지의 문제는 이런 입력이 불가능하다는 것에 기반합니다.
우리는 러스트에서는 &mut
를 복제하는 것이 허락되지 않기 때문에, 이런 입력이 불가능하다는 것을 압니다. 따라서 우리는 안전하게 그런 가능성을 거부하고 최적화를 실행할 수 있게 됩니다.
다른 대부분의 언어들에서는 이런 입력 또한 완전히 가능할 것이고, 고려되어야 할 것입니다.
이것이 바로 복제 분석이 중요한 이유입니다: 유용한 최적화를 컴파일러가 실행하도록 해 주거든요! 몇 가지 예를 들자면:
- 값의 메모리를 접근하는 포인터가 없다는 것을 증명함으로써 값들을 레지스터에 그대로 두는 것
- 어떤 메모리는 마지막으로 읽은 후에 쓴 적이 없다는 것을 증명함으로써 읽기 작업들을 제거하는 것
- 어떤 메모리는 다음 쓰기 작업 전에 읽은 적이 없다는 것을 증명함으로써 쓰기 작업들을 제거하는 것
- 읽기 작업들이나 쓰기 작업들이 서로에 의존하지 않는다는 것을 증명함으로써 작업들을 옭기거나 순서를 바꾸는 것
이런 최적화는 또한 루프 벡터화, 상수 전파, 죽은 코드 제거 등의 더 큰 최적화의 건전함을 증명하게 되는 경향이 있습니다.
이전의 예제에서, 우리는 &mut u32
가 복제될 수 없다는 사실을 이용해서 *output
에 쓰는 작업이 *input
에 영향을 줄 수 없다는 것을 증명했습니다. 이러면 우리는 레지스터에 *input
을 캐싱해서, 읽기 작업을 제거할 수 있습니다.
이 읽기 작업을 캐싱함으로써, 우리는 > 10
분기에서 있는 쓰기 작업이 > 5
분기를 택하는지 여부를 영향주지 못한다는 것을 알게 되고, *input > 10
일 때에 읽고, 수정하고, 다시 쓰는 작업(*output
을 2배로 하는 작업)을 제거할 수 있게 됩니다.
복제 분석에 대해 꼭 기억해야 할 것은 쓰기 작업이 최적화를 방해하는 주된 걸림돌이라는 것입니다. 이 말은, 읽기 작업을 프로그램의 다른 곳으로 옮기는 것을 방해하는 유일한 것은 우리가 그것과 같은 메모리 위치에 쓰는 작업과 함께 순서를 바꾸는 것입니다.
예를 들어, 우리의 함수를 이렇게 수정한 버전이라면 우리는 복제에 대해서 걱정할 필요가 없는데, 왜냐하면 *output
에 쓰는 유일한 작업을 함수의 가장 끝으로 옮겼기 때문입니다. 이는 우리가 이 쓰기 작업 이전에 있는, *input
을 읽는 작업들을 자유롭게 재배치할 수 있다는 것을 의미합니다:
#![allow(unused)] fn main() { fn compute(input: &u32, output: &mut u32) { let mut temp = *output; if *input > 10 { temp = 1; } if *input > 5 { temp *= 2; } *output = temp; } }
우리는 아직도 input
이 temp
의 복제가 아니라는 것을 짐작하기 위해 복제 분석에 의존하지만, 증명은 훨씬 간단해집니다: 지역 변수의 값은 그것이 정의되기 전에 존재하던 것으로 복제할 수 없기 때문입니다.
이것은 모든 언어가 자유롭게 하는 짐작이고, 그래서 이 버전의 함수는 어느 언어에서든 우리가 원하는 대로 최적화시킬 수 있게 됩니다.
이것이 바로 러스트가 쓰는 "복제"의 정의가 살아있음과 변경 같은 개념이 동반되는 이유입니다: 실제로 메모리에 쓰는 작업이 없으면, 복제가 일어나도 상관없기 때문입니다.
물론, 러스트를 위한 총체적인 복제 모델은 함수 호출(보이지 않는 것들을 변경할 수도 있음)이나, 생 포인터 (그들 자체로는 복제의 요구사항이 없음),
그리고 UnsafeCell
(&
로 참조한 레퍼런스의 주체의 값이 변경되도록 허용함) 같은 것들도 고려해야만 합니다.