DeepintoPharo:Chapter 17

From 흡혈양파의 번역工房
Jump to navigation Jump to search
제 17 장 애플리케이션 프로파일링하기

애플리케이션 프로파일링하기

소프트웨어 공학이 시작되던 때부터 프로그래머들은 애플리케이션 성능과 관련된 문제에 직면해왔다. 더 낫고 빠른 개발 프로세스를 지원하도록 프로그래밍 환경이 크게 개선되었지만 프로그래밍 시 성능 문제를 다루기 위해서는 꽤 많은 재주가 필요하다.


대체적으로 애플리케이션을 최적화하는 일은 그다지 어렵지 않다. 일반적인 개념은, 느리고 자주 호출되는 메서드를 더 빠르거나 덜 자주 호출되도록 만드는 데에 있다. 애플리케이션의 최적화는 보통 애플리케이션을 복잡하게 만든다. 따라서 애플리케이션에 대한 요구조건이 잘 이해하고 다루었을 때에만 최적화를 권한다. 다시 말하자면, 무엇을 하는지 확신이 설 때에만 애플리케이션을 최적화해야 한다는 말이다. Kent Back이 말했듯이, 첫째, 작동하게 만들고, 둘째, 올바로 만들고, 셋째, 빠르게 만들도록 하라.


프로파일링은 무엇을 의미하는가?

애플리케이션의 프로파일링은 제어된 프로그램 실행으로부터 동적 정보를 얻음을 나타낼 때 자주 사용되는 용어다. 수집된 정보는 프로그램 실행을 향상시키는 방법에 관한 중요한 힌트를 제공하는 데에 목적이 있다. 이러한 힌트는 주로 수치상 측정(numerical measurements)으로서, 프로그래밍 실행을 서로 쉽게 비교할 수 있다.


이번 장에서는 메서드 실행 시간과 메모리 소모량에 관련된 측정을 고려할 것이다. 프로그래밍 실행에서 다른 유형의 정보도 추출이 가능하며, 특히 메서드 호출 그래프를 예로 들 수 있겠다.


프로그램 실행이 주로 보편적인 80-20 규칙을 따르기 때문에 총 메서드 중에서 소량만이 (20%라고 해보자) 이용 가능한 자원에서 가장 큰 부분을 (메모리와 CPU 소모의 80%) 소모한다는 관찰은 참 흥미롭다. 애플리케이션의 최적화는 따라서 기본적으로 균형(tradeoff)의 문제다. 이번 장에서는 이러한 20%의 메서드를 재빠르게 확인하는 데에 이용 가능한 툴의 사용법을 살펴보고, 우리가 제시한 프로그램 개선에 따른 경과를 측정하는 방법도 살펴볼 것이다.


경험상 프로그램의 최적화 시 프로그램 구문을 망가뜨리지 않도록 확보하는 데에 있어 단위 시험(unit test)은 필수적이다. 기존 알고리즘이 다른 것으로 대체되더라도 프로그램은 본래의 기능을 수행하도록 확보해야 한다.


간단한 예제

메서드 Collection>>select:thenCollect:를 고려해보자. 주어진 컬렉션에서 해당 메서드는 술부(predicate)를 이용해 요소를 선택한다. 이후 선택된 요소마다 블록 함수를 적용한다. 언뜻 보면 이러한 행위는 컬렉션에 2회의 run을 의미하는 것처럼 보여, 하나는 select:thenCollect:의 사용자가 제공하고, 나머지 하나는 선택된 요소들을 포함하는 중간 컬렉션(intermediate)처럼 보인다. 하지만 중간 컬렉션이 꼭 필요한 것은 아닌데, 선택과 함수 애플리케이션은 한 번의 run으로도 실행 가능하기 때문이다.


timeToRun 메서드. 하나의 프로그램 실행을 프로파일링하는 것만으로는 최적화되어야 하는 내용을 완전히 식별하고 확인하기엔 충분치 않다. 프로파일된 실행을 최소 두 개를 비교하는 것은 매우 유익하다. TimeToRun 메시지를 bloc으로 전송하여 블록을 평가하는 데에 소요된 시간을 밀리 초로 얻을 수도 있다. 의미 있고 표현적인 측정을 갖기 위해서는 루프를 이용해 프로파일링을 "증폭"시킬 필요가 있겠다.


결과를 조금만 소개하겠다.

| coll |
coll := #(1 2 3 4 5 6 7 8) asOrderedCollection.
[ 100000 timesRepeat: [ (coll select: [:each | each > 5]) collect: [:i |i*i]]] timeToRun
"Calling select:, then collect: - −→ ∼ 570 - 600 ms"

| coll |
coll := #(1 2 3 4 5 6 7 8) asOrderedCollection.
[ 100000 timesRepeat: [ coll select: [:each | each > 5] thenCollect:[:i |i*i]]] timeToRun
"Calling select:thenCollect: - −→ ∼ 397 - 415 ms"


두 실행 간 차이는 몇 백 밀리초에 불과하지만 둘 중 하나는 애플리케이션 속도를 상당히 저하시킬 수 있다!


select:thenCollect: 의 정의를 자세히 살펴보자. 최적화되지 않은 원본 구현은 Collection에서 찾을 수 있다. (Collection은 Pharo 컬렉션 라이브러리의 루트 클래스임을 명시하라.) 좀 더 효율적인 구현은 해당 연산을 효율적으로 실행하기 위해 정렬된 컬렉션의 구조를 고려하는 OrderedCollection에서 정의된다.

Collection>>select: selectBlock thenCollect: collectBlock
    "Utility method to improve readability."

    ^ (self select: selectBlock) collect: collectBlock

OrderedCollection>>select: selectBlock thenCollect: collectBlock
    " Utility method to improve readability.
    Do not create the intermediate collection. "

    | newCollection |
    newCollection := self copyEmpty.
    firstIndex to: lastIndex do: [:index |
        | element |
        element := array at: index.
        (selectBlock value: element)
            ifTrue: [ newCollection addLast: (collectBlock value: element) ]].
    ^ newCollection


이미 짐작했겠지만 Set나 Dictionary와 같은 다른 컬렉션들은 최적화 버전에서 이익을 얻지 못한다. 다른 추상적 데이터 타입에 대한 효율적인 구현은 각자의 연습을 위해 남겨두겠다. 공동체를 위해 혹시 select:thenCollect: 이나 다른 메서드에서 더 낫고 최적화된 버전이 생각나면 Pharo에 연락할 것을 잊지 말라.


bench 메서드. bench 메시지는 블록으로 전송 시 해당 블록이 초당 평가되는 횟수를 추정한다. 예를 들어, 표현식 [1000 factorial] bench 는 1000 factorial 이 초당 약 350회 실행될 것이라고 말한다.


Pharo 에서 코드 프로파일링하기

timeToRun 메서드는 표현식이 실행되는 데에 소요되는 시간을 알려주는 데 유용하다. 하지만 표현식을 평가하여 트리거된 계산에 실행 시간이 어떻게 분배되는지를 이해하기엔 적절하지 않다. Pharo에는 MessageTally라는 코드 프로파일러가 제공되어 계산에 대한 시간 분배를 정밀하게 분석하도록 해준다.

그림 17.1: 실행 중인 MessageTally.


MessageTally

MessageTally는 동일한 이름을 가진 유일한 클래스로서 구현된다. 이는 사용법이 꽤 간단하다. 세부적인 실행 분석을 얻기 위해선 블록 표현식을 인자로 한 spyOn: 메시지를 MessageTally로 전송하면 된다. MessageTally spyOn: ["your expression here"]를 평가하면 아래 정보가 포함된 창이 열린다.

  1. 표현식이 실행되는 동안 실행된 메서드와 함께 연관된 실행 시간을 표시하는 계층구조 리스트.
  2. 실행의 leaf 메서드. Leaf 메서드는 다른 메서드를 (예: 프리미티브, 접근자) 호출하지 않는 메서드다.
  3. 메모리 소모량이나 쓰레기 수집기 관계 여부를 알려주는 통계.


요점은 후에 하나씩 설명하겠다.


그림 17.2 MessageTally를 이용해 실행된 메서드를 탐색하는 TimeProfiler.

그림 17.2는 MessageTally spyOn: [20 timesRepeat: [Transcript show: 1000 factorial printString]] 표현식 결과를 보여준다. 메시지 spyOn: 는 제공된 블록을 새 프로세스에서 실행한다. 분석은 하나의 프로세스, 즉 프로파일링할 블록을 실행하는 프로세스에만 집중한다. SpyAllOn: 메시지는 실행 도중에 활성화된 모든 프로세스를 프로파일한다. 이는 여러 프로세스 상에서 이루어지는 계산 분배를 분석하는 데에 유용하다.


MessageTally보다 조금 더 간단한 툴로 TimeProfileBrowser가 있다. 이는 실행된 메서드의 구현도 표시한다 (그림 17.2). TimeProfileBrowser는 spyOn: 메시지를 이해한다. 즉, 아래 소스 코드에서 MessageTally를 TimeProfileBrowser로 대체하면 더 나은 사용자 인터페이스를 얻을 수 있다는 뜻이다.


프로그래밍 환경으로 통합

앞서 살펴봤듯이 프로파일러는 MessageTally 클래스로 spyOn: 와 spyAllOn: 을 전송하면 직접 호출할 수 있다. 그 외에도 여러 방법들을 통해 접근이 가능하다.


World 메뉴를 통해. World 메뉴(Pharo 창 밖을 클릭)는 System 하위메뉴를 통해 몇 가지 프로파일링 기능을 제공한다 (그림 17.3). Start profiling all processes는 텍스트 선택내용으로부터 블록을 생성하여 spyAllOn: 을 호출한다. Start profiling UI 엔트리를 선택하면 사용자 인터페이스 프로세스를 프로파일링한다. 이는 사용자 인터페이스를 디버깅 시 매우 유용하다!

그림 17.3: 메뉴를 통한 접근.


Test Runner를 통해. 애플리케이션 크기가 증가하면서 단위 시험은 코드 프로파일링에 적합한 후보자가 되는 것이 보통이다. 테스트 실행 시간이 너무 길어질 경우 테스트의 실행은 지루해진다. Pharo의 Test Runner에는 Run Profiled 버튼이 제공된다 (그림 17.4).


이 버튼을 누르면 선택된 단위 시험을 실행하고 메시지 tally 보고서를 생성한다.


결과를 읽고 해석하기

메시지 tally 프로파일러는 기본적으로 두 가지 정보를 제공한다.

  • 실행 시간은 프로파일링된 코드 실행을 나타내는 트리를 이용해 표시된다 (**Tree**). 해당 트리의 각 노드에는 각 leaf 메서드에서 소비된 시간이 표시된다 (**Leaves**).
  • 메모리 활동은 메모리 소모량과 (**Memory**) 쓰레기 수집기 사용을 (**GC**) 포함한다.


설명을 위해 다음 시나리오를 살펴보겠다. 문자열 'A'는 초기의 빈 문자열에 9000번 누적으로 추가된다.

그림 17.4: TestRunner에 Tally 메시지를 생성하기 위한 버튼.

MessageTally spyOn:
    [ 500 timesRepeat: [
        | str |
        str := ''.
        9000 timesRepeat: [ str := str, 'A' ]]].


완전한 결과는 아래와 같다.

- 24038 tallies, 24081 msec.

**Tree**
--------------------------------
Process: (40s) 535298048: nil
--------------------------------
29.7% {7152ms} primitives
11.5% {2759ms} ByteString(SequenceableCollection)>>copyReplaceFrom:to:with:
5.9% {1410ms} primitives
5.6% {1349ms} ByteString class(String class)>>new:

**Leaves**
	29.7% {7152ms} ByteString(SequenceableCollection)>>,
	9.2% {2226ms} SmallInteger(Integer)>>timesRepeat:
	5.9% {1410ms} ByteString(SequenceableCollection)>>copyReplaceFrom:to:with:
	5.6% {1349ms} ByteString class(String class)>>new:
	4.4% {1052ms} UndefinedObject>>DoIt

**Memory**
	old +0 bytes
	young +9,536 bytes
	used +9,536 bytes
	free -9,536 bytes

**GCs**
	full 0 totalling 0ms (0.0% uptime)
	incr 9707 totalling 7,985ms (16.0% uptime), avg 1.0ms
	tenures 0
	root table 0 overflows


첫 행은 전체 실행 시간과 샘플링 개수(tally라고도 불리는데 샘플링에 관한 내용은 본 장의 끝 부분에서 다시 설명할 것이다)를 제공한다.


**Tree**: 누계(Cumulative) 정보

**Tree** 부분은 프로세스당 실행 트리를 나타낸다. 트리는 Pharo 해석기가 각 메서드에서 소비한 시간을 알려준다. 뿐만 아니라 호출 그래프를 이용해 여러 호출을 알려주기도 한다. 서로 다른 실행 흐름은 그들이 실행된 프로세스에 따라 구분된 채 유지된다. 프로세스 우선순위도 표시되어 서로 다른 프로세스를 구별하는 데에 도움이 된다. 예제는 아래와 같이 알려준다.

**Tree**
--------------------------------
Process: (40s) 535298048: nil
--------------------------------
29.7% {7152ms} primitives
11.5% {2759ms} ByteString(SequenceableCollection)>>copyReplaceFrom:to:with:
5.9% {1410ms} primitives
5.6% {1349ms} ByteString class(String class)>>new:


해당 트리는 해석기가 총 실행 시간 중 29.7%를 프리미티브의 실행에 소비하였음을 보여준다. 총 실행 시간 중에서 11.5%는 SequenceableCollection>>copyReplaceFrom:to:with:메서드에 소비되었다. 해당 메서드는 comma(,) 메시지를 이용해 문자열을 연결(concatenate)할 때 호출되는데, 콤마(,) 자체는 new: 와 몇몇 가상 머신 프리미티브를 간접적으로 호출한다.


실행(execution)에는 총 실행 시간의 11.5%가 소비되었는데, 이는 해석기의 결과가 다른 프로세스와 공유됨을 의미한다. 코드부터 프리미티브로 연결된 호출 사슬은 상대적으로 짧다. 해당 예제는 후에 최적화할 것이다.


두 개의 프리미티브가 트리 leaf로서 열거된다. 이들은 서로 다른 프리미티브에 해당한다. 불행히도 MessageTally는 둘 중 어떤 프리미티브가 호출되었는지 알려주지 않는다.


**Leaves**: leaf 메서드

**Leaves** 부분은 프로파일링된 코드 블록에 대한 leaf 메서드를 열거한다. Leaf 메서드는 다른 메서드를 호출하지 않는 메서드를 의미한다. 좀 더 정확히 말하자면 이것은 메서드 m으로서, 메서드 m이 호출한 메서드는 어떤 것도 "감지되지" 않는다. 이는 변수 접근자(예: Point>>), 프리미티브 메서드, 매우 빠르게 실행되는 메서드의 경우에 해당한다. 앞의 예제에서 우리는 아래를 얻는다.

**Leaves**
29.7% {7152ms} ByteString(SequenceableCollection)>>,
9.2% {2226ms} SmallInteger(Integer)>>timesRepeat:
5.9% {1410ms} ByteString(SequenceableCollection)>>copyReplaceFrom:to:with:
5.6% {1349ms} ByteString class(String class)>>new:
4.4% {1052ms} UndefinedObject>>DoIt


**Memory**

메모리 소모량에 관한 통계 부분을 보면 할당된 메모리 양과 쓰레기 수집기 사용에서 관찰된 변경 사항을 알려준다. 이러한 정보를 완전히 이해하려면, Pharo의 쓰레기 수집기(GC)는 scavenging GC여서, 오래된 객체가 심지어 더 오래 생존하기 위해 큰 변화를 갖는다는 원칙에 의존할 필요가 있다. 이는 객체가 향후에 참조된 채로 유지될 것이란 사실에 따라 설계된다. 반면 young 객체도 빠르게 참조해제(dereference)될 변경 내용을 상당히 많이 갖는다.


몇몇 메모리 존(zone)이 고려되며, 새로운 객체가 오래된 객체 전용의 공간으로 이동하면 tenured될 자격이 있다 (아래 미국 학술 과학자들에 대한 비유에 따라 정규직을 얻게 되면 그러한 자격이 생긴다).


MessageTally를 이용해 실현된 메모리 분석의 예제는 다음과 같다.

**Memory**
old 			+0 bytes
young 		+9,536 bytes
used 		+9,536 bytes
free 		-9,536 bytes


MessageTally는 네 가지 값을 이용해 메모리 사용을 설명한다.

  1. old 값은 old 객체 전용의 메모리 공간 증가와 관련이 있다. 객체는 그 물리적 메모리 위치가 "오래된 메모리 공간"에 있을 때 "오래되었다"는 용어에 적격하다. 이는 전체 쓰레기 수집기가 트리거되거나, 너무 많은 객체 생존자들이 있는 경우(가상 머신에 명시된 몇몇 한계선에 따라) 발생한다. 이러한 메모리 공간은 전체적인 쓰레기 수집에 의해서만 청소된다. (따라서 점진적 GC는 그 크기를 축소시키지 않는다.)
    오래된 메모리 공간이 증가하는 이유는 메모리 누수 때문일 가능성이 크다. 가상 머신은 메모리를 해제(release)할 수 없었고, young 객체들을 old 객체로 발전시켰다.
  2. young 값은 young 객체 전용의 메모리 공간의 증가를 알려준다. 객체가 생성되면 물리적으로 해당 메모리 공간에 위치한다. 해당 메모리 공간의 크기는 수시로 변경된다.
  3. used 값은 총 사용된 메모리량이다.
  4. free 값은 이용 가능한 메모리량이다.


우리 예제에서는 실행 중 생성된 어떤 객체도 old 객체로 발전하지 않았다. 9 536 바이트가 현재 프로세스에서 사용되었고, young 메모리 공간에 위치하였다. 이용 가능한 메모리량은 그에 따라 감소되었다.


**GCs**

**GCs**는 쓰레기 수집기에 관한 통계를 제공한다. 쓰레기 수집기 보고서의 예를 들자면 아래와 같다.

**GCs**
full 				0 totalling 0ms (0.0% uptime)
incr 			9707 totalling 7,985ms (16.0% uptime), avg 1.0ms
tenures 		1 (avg 9707 GCs/tenure)
root table 		0 overflows


네 가지 값이 이용 가능하다.

  1. full 값은 전체 쓰레기 수집 양과 그에 소요된 시간량을 합계 낸다. 전체 쓰레기 수집은 그다지 자주 발생하지 않는다. 이는 큰 메모리 청크를 자주 할당한 결과이다.
  2. incr는 점진적(incremental0 쓰레기 수집에 관한 것이다. 점진적 GC는 자주 발생하고 (초당 몇 회) 빠르게 실행되는 (약 1 또는 2ms) 것이 보통이다. 점진적 GC에 소요된 시간은 10% 미만으로 유지하는 것이 바람직하다.
  3. tenures 개수는 old 메모리 공간으로 이동한 객체량을 말해준다. 이러한 이동은 young 메모리 공간의 크기가 주어진 한계치보다 위에 있을 때 발생한다. 이는 보통 애플리케이션을 시작할 때 발생하고, 모든 필수 객체가 생성되지 않았거나 참조되지 않았을 때 발생한다.
  4. root table overflows는 쓰레기 수집기가 이미지를 탐색 시 사용하는 루트 객체의 양이다. 이러한 탐색은 시스템에 메모리가 부족하여 향후 프로그램 실행에 관련된 모든 객체를 수집할 필요가 있을 때 발생한다. Overflow 값은 점진적 GC에 의해 사용되는 루트 개수가 그 내부 테이블보다 큰 경우처럼 드문 상황을 식별한다. 이러한 상황이 발생하면 GC로 하여금 일부 객체를 강제로 tenured 상태로 만든다.


예제에서는 점진적 GC만 사용됨을 확인할 수 있다. 잇따라 살펴보겠지만 생성된 객체의 양은 성능을 최적화하고자 할 때와 관련이 있다.


실례를 이용한 분석

프로파일링 시 얻은 결과를 이해하는 것은 애플리케이션을 최적화하고자 할 때 취해야 하는 첫 번째 단계다. 하지만 지금쯤 느끼겠지만 계산이 번거로운 이유를 이해하는 일도 간과해서는 안 된다. 많은 예제를 바탕으로 우리는 여러 프로파일링 결과를 비교하는 것이 번거로운 메시지 호출을 식별하는 데에 어떻게 도움이 되는지 살펴볼 것이다.


"," 메서드는 새 문자열을 생성하고 수신자와 인자를 모두 그 문자열로 복사하기 때문에 속도가 느린 것으로 알려져 있다. Stream을 사용하면 문자열을 연결하기가 훨씬 빠르다. 하지만 nextPut:과 nextPutAll: 은 사용 시 주의를 기울여야 한다!


문자열 연결에 Stream 사용하기. 언뜻 보면 스트림은 주로 상대적으로 느린 입/출력과(예: 네트워크 소켓, 디스크 접근, Transcript) 함께 사용되기 때문에 스트림을 생성하기가 번거롭다고 생각할 수도 있다. 하지만 앞의 예제에 사용된 문자열을 스트림 연산으로 대체하면 약 10배가 빨라진다! 문자열을 9000회 연결하면 8999개의 중간 객체를 생성하고, 각 객체는 다른 객체의 내용으로 채워져 있다고 생각하면 이해가 쉬울 것이다. 스트림을 이용하면 각 반복(iteration)에서 문자를 추가해야 한다.

MessageTally spyOn:
    [ 500 timesRepeat: [
        | str |
        str := WriteStream on: (String new).
        9000 timesRepeat: [ str nextPut: $A ]]].
- 807 tallies, 807 msec.

**Tree**
--------------------------------
Process: (40s) 535298048: nil
--------------------------------

**Leaves**
33.0% {266ms} SmallInteger(Integer)>>timesRepeat:
21.2% {171ms} UndefinedObject>>DoIt

**Memory**
old 			+0 bytes
young 		-18,272 bytes
used 		-18,272 bytes
free 		+18,272 bytes

**GCs**
full 			0 totalling 0ms (0.0% uptime)
incr 		5 totalling 7ms (3.0% uptime), avg 1.0ms
tenures 	0
root table 	0 overflows


문자열 사전할당. 컬렉션의 사전할당 없이 OrderedCollection을 이용하는 데에는 수고가 많이 드는 것으로 알려져 있다. 컬렉션이 가득 찰 때마다 그 내용은 더 큰 컬렉션으로 복사되어야 한다. 주의 깊게 선택한 사전할당은 정렬된 컬렉션을 이용하는 효과를 갖는다. new 메시지 대신 new: aNumber 메시지를 사용할 수 있다.

MessageTally spyOn:
    [ 500 timesRepeat: [
        | str |
        str := WriteStream on: (String new: 9000).
        9000 timesRepeat: [ str nextPut: $A ]]].


이 예제에서는 atAllPut: 메서드를 이용하여 스크립트를 개선하는 것이 가능하다. 아래 스크립트에 소요되는 시간은 몇 밀리초에 불과하다.

MessageTally spyOn:
    [ 500 timesRepeat: [
        | str |
        str :=String new: 9000.
        str atAllPut: $A ]].


실험. 벤치마크를 실행하는 것은 서로 다른 실행을 비교할 때 그 빛을 발한다. 앞의 코드 조각에서 값 9000을 500으로 대체하면 매우 유익하다. 9000 회 반복에 소요된 시간은 500에 소요된 시간보다 2.6배 정도 느리다. 스트림 대신 문자열 연결(예: , 메서드를 이용해)을 이용하면 factor 10과의 차이를 넓힐 수 있다. 이러한 실험은 문자열의 연결에 적절한 툴을 이용하는 중요성을 분명히 보여준다.


프로파일링된 실행 시간 또한 결과의 중요한 질적 요인이 된다. MessageTally는 코드를 프로파일에 샘플링 기법을 사용한다. default마다 MessageTally는 default별로 현재 실행되는 스레드(thread)를 샘플링한다. 따라서 계산에 관여된 모든 메서드가 결과 보고서에 나타나기 위해 "적정한" 시간만큼 실행된다. 프로파일링할 애플리케이션이 너무 짧은 경우(몇 밀리초에 불과할 경우) 여러 번 실행하면 보고서의 정확성을 향상시키는 데에 도움이 된다.


메시지 계수하기

지금까지 알아본 프로파일링은 메서드 실행 시간에 초점을 둔다. 메서드 호출 스택 샘플링의 이점으로는 실행에 상대적으로 적은 영향을 미친다는 점이 있다. 단점은 결과가 상대적으로 정밀하지 못하다는 점이다. 대부분의 경우, 이로 얻은 결과로 충분하지만 어찌되었든 항상 실제 실행의 근사치(approximation)에 해당한다.


MessageTally는 프로그램 해석을 기반으로 한 프로파일링을 허용한다. 이는 실행 샘플러 대신 바이트코드 해석기를 사용하는 개념이다. 주요 장점으로 결과의 정확도를 들 수 있다. tallySends: 메시지로 얻은 정보는 계산에 수반된 각 메서드가 실행된 시간을 나타낸다. 그림 17.5는 아래를 실행해 얻은 결과를 제시한다.

MessageTally tallySends:[ 1000 timesRepeat: [3.14159 printString]].


TallySends: 의 단점은 제공된 블록을 실행하는 데에 소요되는 시간을 들 수 있다. 프로파일링할 블록은 Pharo에서 작성된 해석기에 의해 실행되는데, 이는 가상 머신의 해석기보다 속도가 느리다. tallySends: 에 의해 프로파일링되는 코드 조각은 200배 가량이 느리다. 해석기는 ContextPart>>runSimulated: aBlock contextAtEachStep: block2 메서드로부터 이용 가능하다.


기억된 피보나치

앞서 살펴본 기법들을 약간 적용하기 위해 피보나치 함수 function (fib(n) = fib(n − 1) + fib(n − 2) with fib(0) = 0, fib(1) = 1)를 고려해보자. 우리는 두 가지 버전을 살펴볼 것인데, 하나는 재귀적 버전이고, 하나는기억된 버전이다. 저장하기(memoizing)는 중복 계산을 피하기 위해 캐시를 도입하는 특징이 있다.


수학적 정의에 가까운 아래 정의를 고려해보자.

그림 17.5: 실행(execution) 중에 실행된 모든 메시지.

Integer>>fibSlow
    self assert: self >= 0.
    (self <= 1) ifTrue: [ ^ self].
    ^ (self - 1) fibSlow + (self - 2) fibSlow


fibSlow 메서드는 상대적으로 비효율적이다. 각 재귀는 계산의 중복을 나타낸다. 재귀의 각 branch에 의해 동일한 결과가 두 번 계산된다.


좀 더 효율적인 (하지만 약간은 더 복잡한) 버전은 중간(intermediary) 계산 값을 보관하는 캐시를 이용하여 얻는다. 이의 장점은 각 값이 한 번만 계산되기 때문에 계산이 중복되지 않는다는 데에 있다. 이러한 전통적인 프로그램 최적화 방식을 저장하기(memoizing)라 부른다.

Integer>>fib
    ^ self fibWithCache: (Array new: self)

Integer>>fibLookup: cache
    | res |
    res := cache at: (self + 1).
    ^ res ifNil: [ cache at: (self + 1) put: (self fibWithCache: cache ) ]

Integer>>fibWithCache: cache
    (self <= 1) ifTrue: [ ^ self].
    ^ ((self - 1) fibLookup: cache) + ((self - 2) fibLookup: cache)


실습으로 저장하기 방식의 장점을 확실히 살펴보도록 35 fibSlow와 35fib를 프로파일링하라.


Class별 메모리 소모량에 대한 SpaceTally

주어진 클래스의 메모리 소모량과 인스턴스의 개수를 아는 것은 때때로 중요하다. SpaceTally 클래스가 바로 이러한 기능을 제공한다.


SpaceTally newprintSpaceAnalysis 표현식은 시스템의 모든 클래스를 훑어보면서 각 클래스마다 코드 크기, 인스턴스의 개수, 인스턴스가 차지하는 총 메모리 공간을 수집한다. 결과는 인스턴스가 차지하는 총 메모리 공간에 따라 정렬되고, Pharo 이미지 바로 옆에 위치한 STspace.text라는 파일에 보관된다.


문자열, 컴파일된 메서드와 비트맵이 Pharo의 메모리에서 가장 큰 부분을 표현한다는 사실은 놀라운 일이 아니다. 다른 플랫폼에서는 다양한 애플리케이션을 위해 컴파일된 코드, 문자열, 비트맵이 차지하는 비율을 찾을 수 있을 것이다.


SpaceTally의 출력은 아래와 같이 구조화된다.

Class code space # instances inst space percent inst average size
ByteString 2053 109613 9133154 31.20 83.32
Bitmap 3653 379 6122156 20.90 16153.45
CompiledMethod 20067 51579 3307151 11.30 64.12
Array 2535 85560 3071680 10.50 35.90
ByteSymbol 1086 35746 914367 3.10 25.58
...


각 행은 Pharo 클래스의 메모리 분석을 나타낸다. 클래스는 그들이 차지하는 공간에 따라 정렬된다. ByteString 클래스는 문자열을 설명하는데, 이는 종종 메모리의 1/3을 소모하기 위한 문자열을 갖는다. 코드 공간은 클래스와 그 메타클래스가 사용하는 바이트량을 제공한다. 클래스 변수에 의해 사용되는 공간은 포함하지 않는다. 그 값은 Behavior>>spaceUsed 메서드에 의해 주어진다.


# instances 열은 인스턴스의 양을 제공한다. 이는 Behavior>>instanceCount의 결과이다. Inst space 열은 모든 인스턴스에 의해 소모되는 바이트량을 의미하는데, 객체 헤더도 이에 포함된다. 이는 Behavior>>instancesSizeInMemory의 결과다. 메모리 차지 비율은 percent 열에서 주어지고, 마지막 열은 인스턴스의 평균 크기를 제시한다.


모든 클래스 상에서 SpaceTally를 실행하는 데에는 몇 분의 시간이 소요된다. SpaceTally는 분석 시간을 증가시키기 위해 축소된 클래스 집합에서 실행되기도 한다. 아래를 고려해보자.

((SpaceTally new spaceTally: (Array with: TextMorph with: Point))
    asSortedCollection: [:a :b | a spaceForInstances > b spaceForInstances])


SpaceTally>>spaceTally: 메서드는 그 인자인 각 클래스가 소모하는 메모리를 분석한다. SpaceTallyItem 의 인스턴스 리스트를 리턴한다.


몇 가지 조언

프로그램을 측정하고 최적화하는 다수의 전략을 살펴보았다. 본문에 사용된 예제들은 상대적으로 규모가 작다. 프로그램의 최적화가 항상 쉬운 작업은 아니다. 캐시를 삽입하기 위한 메서드 후보(candidate)를 식별하는 일은 단순하면서 효율적인데, 단 (i) 캐시를 무효화하는 방법을 숙지하고, (ii) 코드를 삽입 시 전체 실행에 미치는 영향을 인식할 때에 한해서다.


일반적으로 leaf 메서드의 최적화를 시도하는 대신 전체 알고리즘을 이해하는 편이 더 중요하다. 데이터가 구조화되는 방식 또한 최적화의 기회를 제공할지도 모른다. 예를 들어 정렬 컬렉션이나 연계된(linked) 리스트는 비순환 그래프를 나타낼 때 사용하기엔 적절하지 않을지도 모른다. 해시값이 적절하게 잘 분배된 경우 dictionary 또는 집합을 이용하면 더 나은 성과를 제공할 수도 있다.


메모리 소모량은 중요한 역할을 수행하기도 한다. 때때로 쓰레기 수집이 요청된다면 전체적인 성능은 크게 감소할지도 모른다. 객체를 재활용하고 불필요한 객체 생성을 피하면 쓰레기 수집기의 요청을 감소시키는 데에 도움이 된다.


MessageTally는 어떻게 구현되는가?

MessageTally는 Pharo의 반영적인 기능을 사용하는 방식을 보여주는 뛰어난 예에 해당한다. SpyEvery: millisecs on: aBlock 메서드는 전체적인 프로파일링 로직을 포함한다. 이 메서드는 spyOn: 에 의해 간접적으로 호출된다. millisecs 값은 각 샘플 간 밀리초를 나타내며, default 별로 1에 설정된다. 프로파일링될 블록은 aBlock이다.


프로파일링 활동의 핵심은 아래에 발췌된 코드 조각에서 주어진다.

observedProcess := Processor activeProcess.
Timer := [
    [ true ] whileTrue: [
        | startTime |
        startTime := Time millisecondClockValue.
        myDelay wait.
        self
            tally: Processor preemptedProcess suspendedContext
            in: (observedProcess == Processor preemptedProcess
                ifTrue: [ observedProcess ] ifFalse: [ nil ])
            by: (Time millisecondClockValue - startTime) // millisecs ].
    nil] newProcess.
Timer priority: Processor timingPriority-1.


Timer는 우선순위가 높게 설정된 새 프로세스로, aBlock의 모니터링을 책임진다. 따라서 프로세스 스케줄러가 순조롭게 활성화할 것이다 (timingPriority는 시스템 프로세스의 프로세스 우선순위다). 이것은 메서드 콜 스택의 스냅샷 이전에 필요한 밀리초(myDelay)만큼 기다리는 무한 루프를 생성한다. 관찰해야 할 프로세스는 observedProcess로, 이는 spyEvery: millisecs on: aBlock 메시지가 전송된 프로세스다.


프로파일링을 하는 목적은 각 메서드 컨텍스트와 카운터를 연관시키는 데에 있다. 이러한 연관은 MessageTally 클래스의 (해당 클래스는 class, method, process 변수를 정의한다) 인스턴스를 이용해 얻는다.


규칙적 간격으로 (myDelay) 각 스택 프레임의 카운터는 지연된(elapsed) 밀리초만큼 증가된다. 스택 프레임은 방금 선점된(preempted) 프로세스로 suspendedContext를 전송하면 얻을 수 있다.


tally: context in: aProcess by: count 메서드는 count에 의해 주어진 밀리초만큼 각 스택 프레임을 증가시킨다.


메모리 통계는 소모된 메모리량을 프로파일링 전과 후로 나누어 제공된다. SmalltalkImage 클래스의 인스턴스인 Smalltalk는 이용 가능한 메모리량을 질의하기 위한 다수의 접근(accessing) 메서드를 포함한다.


요약

이번 장을 통해 우리는 Pharo에서 프로파일링의 기본을 살펴보았다. 본 장에서는 MessageTally의 기능을 제시하였고, 성능 병목현상을 다시 흡수하기 위한 원칙을 여러 가지 소개하였다.

  • timeToRun과 bench 메서드는 단순한 벤치마킹을 제공하며, 블록으로 전송되어야 한다.
  • MessageTally는 샘플링 기반의 코드 프로파일러이다.
  • MessageTally spyOn: [ "an expression" ]을 평가하면 제공된 블록을 평가하고 보고서를 표시한다.
  • 정확도는 프로파일링된 블록의 실행 시간을 증가시켜 달성할 수 있다.
  • Pharo 프로그래밍 환경은 여러 가지 편리한 프로파일링 방식을 제공한다.
  • 메시지를 계수하는 것은 느리지만 정확한 프로파일링 기법이다.
  • 저장하기(memoization)는 실행을 증가시키는 일반적이면서 효율적인 코드 패턴이다.
  • SpaceTally는 메모리 소모량에 관해 보고한다.


Notes