Programing/최적화2008.07.21 13:10

소개

얼마전에 모바일기기에서 일정수준의 품질을 유지하면서 실행되는 JPEG라이브러리를 만드는 프로젝트를 진행한적이 있었다. 이 프로젝트를 진행하면서, 여러가지 방법으로 프로그램을 더 빨리 만들 수 있다는 사실을 경험적으로 알게 되었다. 이 문서는 C로된 코드를 속도와 메모리 양측모두에서 최적화하기 위한 경험적인 정보들을 포함하고 있다.

물론 여러분은 C 코드를 최적화 하는 방법에 대한 참고문서를 어렵지 않게 획득할 수 있을 것이다. 그러나 대부분의 문서가 팁수준에서 문제에 접근할 뿐으로, 컴파일러나 기계수준에서 어떻게 프로그래밍을 해야 하는지에 대한 정보는 담고 있지 않다.

보통 프로그램의 속도를 높이게 되면 코드의 크기가 늘어나게 된다. 코드의 크기가 늘어나면 프로그램이 복잡해지고, 읽고 이해하기 어려워진다. 메모리 자원이 넉넉한 개인PC혹은 서버 컴퓨터라면 문제가 되지 않겠지만 PDA와 같은 제한된 메모리 자원을 가진 기기일 경우 심각한 문제가 될 수 있다. 1%의 속도향상을 위해서 코드의 크기가 10%만큼 늘어난다면 분명 문제가 될 것이다. 이런 이유로 속도와 코드크기 모두에 대한 최적화를 수행하기로 결정을 했다.

선언

내가 진행하는 프로젝트가 ARM 플랫폼에서 진행된 관계로, ARM 최적화와 관련된 팁들이 필요했었다. 나는 인터넷을 통해서 ARM 최적화와 관련된 많은 문서를 검색하고 이중 유용한 것들 중심으로 수집해서 테스트를 했었다. 그러나 대부분의 문서들이 나에게는 도움이 되지 않았음을 고백한다. 이러한 실수를 줄이기 위해서 유용하고 효과적인 몇개의 팁만을 모으기로 결정했다.

어디에 필요한가

토론의 주제를 명확히 하고 넘어가자. 컴퓨터 프로그램을 최적화하기 위한 가장 중요한 것은 프로그램을 이루는 각각의 모듈중 어느 부분이 느리게 작동하거나, 큰 메모리를 소비하는지를 찾아내는 것이다. 이들 각각의 부분을 최적화하면 프로그램이 전체적으로 빨라질 것이기 때문이다. 이러한 모듈단위의 최적화는 최적화를 위한 부분을 비교적 쉽게 찾고, 쉽게 해결할 수 있다는 장점을 가진다.

The optimizations should be done on those parts of the program that are run the most, especially those methods which are called repeatedly by various inner loops that the program can have.

일반적으로 경험이 풍부한 프로그래머들은 아주 쉽게 프로그램이 요구하는 최적화될 필요가 있는 핵심을 쉽게 찾아낼 수 있을 것이다. 가장 좋은 최적화 방법은 경험많은 프로그래머를 고용하는 것이다. 그러나 경험많은 프로그래머는 매우 비싸며, 경험이 많다고 해도 더 좋은 결과를 위해서는 최적화를 위한 좋은 툴을 사용할 필요가 있다. Visual C++ 과 같은 통합 개발환경은 함수단위로 프로그램의 소비시간을 측정할 수 있는 profiler를 제공한다. 리눅스의 경우에는 gprof와 같은 profiler를 사용할 수 있다. 혹은 Intel Vtune와 같은 프로그램을 사용할 수 있는데, 이들 프로그램을 사용하면 프로그램의 어느부분이 가장 많은 시간을 소비하는지를 확인할 수 있다. 개인적인 경험으로 루프 혹은 third party 라이브러리 메서드를 호출하는 영역이 프로그램을 느리게 하는 경우가 많았다.

정수

우리가 사용할 값이 음수가 아니라면 int 형대신에 unsigned int형을 사용해야 한다. 어떤 프로세스들은 unsigned integer의 연산이 signed 연산보다 매우 빠르다. 음수를 사용할 경우 2의보수 연산을 추가로 해줘야 하기 때문이다. 2의보수 연산에 대한 내용은 C 프로그래밍 문서를 참고하기 바란다. 문서를 보면 왜 signed 연산에 더 많은 시간이 소비되는지를 알 수 있을 것이다.

루프에 사용될 변수라고 한다면, 다음과 같이 깔끔하고 효율적으로 선언할 수 있을 것이다.
register unsigned int variable_name; 
 

기억해야할 또다른 점은 floating point 연산은 매우 느리다라는 점이다. floating point 데이터 타입은 자바와 함께 하는 컴퓨터과학문서를 참고하기 바란다. 척 봐도 floating point 숫자는 다루기가 꽤나 복잡하다는 것을 알 수 있을 것이다. 만약 여러분이 소숫점 2자리까지의 정확도를 유지하는 회계프로그램을 만든다면, 모든 값에 x100을해서 int 형으로 바꾼다음 연산을 하도록 한다. 가능하면 외부의 수학라이브러리를 사용하지 않도록 한다. FPUs와 같은 라이브러리는 매우 느리다.

나눗셈 그리고 나머지

표준적인 프로세서에서의 분모와 분자의 32bit 나눗셈은 20~140의 실행 사이클을 가지고 있다. 나눗셈을 이용하면 다음과 같은 시간이 소비된다.
Time (numerator / denominator) = C0 + C1* log2 (numerator / denominator) 
= C0 + C1 * (log2 (numerator) - log2 (denominator)). 
 
널리 쓰이는 버젼은 약 20+4.3N의 사이클을 보여준다. ARM 뿐만 아니라 프로세서를 막론하고 이런 연산은 피하는게 바람직하다. 나눗셈연산은 가능하다면 곱셈으로 대체해서 사용하기 바란다.

예를들어 (a/b) > c 는 b * c가 integer 범위안이라는 것을 안다면 a > (c * b)로 다시 쓰일 수 있다.

Combining division and remainder

나눗셈 (x/y) 그리고 나머지(x%y)둘다 종종 필요한 케이스이다
그러한 케이스에 비추어보아 나눗셈펑션을 컴파일러에 결합하는것이좋다 왜냐하면 나눗셈펑션은 항상 나눈값과 나머지를 리턴하기 필요하다 만약둘다 필요하다면 우리는 이와같은 예제를 같이 쓸수있어야한다
int func_div_and_mod (int a, int b) { 
        return (a / b) + (a % b); 
    } 
 

Division and remainder by powers of two

2의 배수로 나누기

나누기를 할 때 2의 배수를 분자로 함으로써, 코드를 더 효율적으로 만들 수 있다. 이경우에 컴파일러는 나누기 연산대신에 shift 연산을 할 수 있기 때문이다. shift 연산은 가장빠른 연산중의 하나다. 그러므로 가능하면 2의 배수로 나눌 수 있도록 스케일을 조절할 필요가 있다. (예를 들어 66으로 나누어야 한다면 64로 나눌 수 있도록 스케일을 조절하라).
typedef unsigned int uint; 
 
    uint div32u (uint a) { 
     return a / 32; 
    } 
    int div32s (int a){ 
     return a / 32; 
    } 
 
이경우에도 signed 값보다는 unsigned 로 나누어질 수 있도록 함수를 조절할 필요가 있다. signed의 경우에는 더많은 시간이 소비된다. 왜냐하면 오른쪽으로 쉬프트 시킬경우 가장왼쪽의 비트를 0으로 만들어주는 연산이 한번더 들어가기 때문이다.

#include <stdio.h> 
 
int main() 
{ 
  unsigned int a = 1024; 
  unsigned b, c; 
  b = a/32;    // --- 1 
  c = a >> 5;  // --- 2 
} 
 
1과 2는 동일한 결과를 보여주며, 컴파일러내에서도 동일하게 shift 처리된다. 다음은 intel 프로세서에서 gcc로 컴파일된 어셈블리어중 1과 2에 해당되는 부분의 코드다.
movl    $1024, -12(%ebp) 
movl    -12(%ebp), %eax 
shrl    $5, %eax           # b = a / 32 
movl    %eax, -8(%ebp) 
movl    -12(%ebp), %eax 
shrl    $5, %eax           # c = a >> 5 
 

배열을 이용한 index 생성

특정값에 대응되는 문자를 변수에 입력하는 코드를 만든다면 다음과 같이 switch 문을 사용할 것이다.
switch ( queue ) { 
  case 0 :   letter = 'W'; 
     break; 
  case 1 :   letter = 'S'; 
     break; 
  case 2 :   letter = 'U'; 
     break; 
} 
 
혹은 if else 문을 사용할 수도 있을 것이다.
 if ( queue == 0 ) 
   letter = 'W'; 
 else if ( queue == 1 ) 
   letter = 'S'; 
 else 
   letter = 'U'; 
 

다음과 같이 문자의 배열을 인덱스화 하면 더 빠른 접근이 가능하다. - 사용하기도 쉽다 -
static char *classes="WSU"; 
letter = classes[queue]; 
 

나머지 연산자의 대체

우리는 나눗셈의 나머지를 알기 위해서 나머지 연산자 %를 사용한다. 이경우 % 연산대신 판단문을 사용해서 시간을 줄일 수 있다. 아래의 두 코드를 비교해 보기 바란다.
uint modulo_func1 (uint count) 
{ 
   return (++count % 60); 
} 
 
uint modulo_func2 (uint count) 
{ 
   if (++count >= 60) 
  count = 0; 
  return (count); 
} 
 
if 문은 나머지 연산자보다 빠른코드를 생성한다. 주의 할점은 2번째 함수의 경우 0에서 60사이의 값에 대해서만 제대로 측정이 된다는 점이다.

전역 변수

전역 변수는 절대 레지스터에 할당할 수 없다. 포인터를 사용하여 간접적으로 할당하거나 함수호출을 이용해서 전역변수를 변환할 수 있다.

따라서 컴파일러는 전역변수의 값을 레지스터에 올려서 캐쉬할 수 없게 되고 때문에 글로벌 변수를 이용할 때마다 다시 읽어들이는 오버로드가 생기게 된다. 그러므로 가능하면 글로벌 변수를 직접 호출하는 대신에, 로컬변수를 이용해서 필요한 연산을 하고 그 결과를 글로별 변수에 할당하는 방법을 사용해야 한다.
int f(void); 
int g(void); 
int h(void); 
int errs; 
void test1(void) 
{ 
  errs += f(); 
  errs += g(); 
  errs += h(); 
} 
void test2(void) 
{ 
  int localerrs = errs; 
  localerrs += f(); 
  localerrs += g(); 
  localerrs += h(); 
  errs = localerrs; 
} 
 
test1은 매번 전역변수를 로드해야 한다. 반면 test2의 경우 레지스터에 등록된 localerrs에 값을 저장하고 마지막에 한번만 전역변수에 접근함을 알 수 있다.

Using Aliases

Using Aliases
Consider the following example -

void func1( int *data ) {
int i;

for(i=0; i<10; i++)
{
anyfunc( *data, i);
}
}
Even though *data may never change, the compiler does not know that anyfunc () did not alter it, and so the program must read it from memory each time it is used - it may be an alias for some other variable that is altered elsewhere. If we know it won't be altered, we could code it like this instead:

void func1( int *data )
{
int i;
int localdata;

localdata = *data;
for(i=0; i<10; i++)
{
anyfunc ( localdata, i);
}
}

This gives the compiler better opportunity for optimization.

Live variables and spilling
As any processor has a fixed set of registers, there is a limit to the number of variables that can be kept in registers at any one point in the program.

Some compilers support live-range splitting, where a variable can be allocated to different registers as well as to memory in different parts of the function. The live-range of a variable is defined as all statements between the last assignment to the variable, and the last usage of the variable before the next assignment. In this range, the value of the variable is valid, thus it is alive. In between live ranges, the value of a variable is not needed: it is dead, so its register can be used for other variables, allowing the compiler to allocate more variables to registers.

The number of registers needed for register-allocatable variables is at least the number of overlapping live-ranges at each point in a function. If this exceeds the number of registers available, some variables must be stored to memory temporarily. This process is called spilling.

The compiler spills the least frequently used variables first, so as to minimize the cost of spilling. Spilling of variables can be avoided by:

Limiting the maximum number of live variables: this is typically achieved by keeping expressions simple and small, and not using too many variables in a function. Subdividing large functions into smaller, simpler ones might also help.
Using register for frequently-used variables: this tells the compiler that the register variable is going to be frequently used, so it should be allocated to a register with a very high priority. However, such a variable may still be spilled in some circumstances.


머릿말
가벼운 모바일 계획을 하는데 그래픽 손상없이 충분히 실행이 가능한 JPEG 라이버리 프로젝트를 진행하는동안 나는 여러가지 방법으로 컴퓨터 프로그램을 더 빠르게 만들수있다는것을 보았다

난 가능한 속도 심지어 메모리까지 c 코드를 최적화하기 위한 이 방법을 모든 경험 그리고 정보를 모았다

비록 다수의 c 최적화코드에 대한 가이드(참고서)를 이용할수있다.
하지만 거기엔 철저한 컴파일러에 대한 지식 그리고 너가 하고있는 프로그래밍을 거기에 대신 가질순 없다

가끔 프로그램의 속도를 낸다는것은 코드의 증가의 원인이 된다

이 코드의 증가는 프로그램을 보충하고 읽는데 역효과를 가지고있다

이것은 너의 모바일과 같은 프로그래밍, PDA 같은 엄격한 메모리의 제한이 필요한 프로그램에서는 받아드릴수 없을것이다

그래서 최적화를 공부 하는동안 우리는 코드안에서 메모리와 스피드 둘다 최적이 되는 방법을 할것이다



선언
사실 나는 프로젝트를 진행하는동안 최적화된 ARM을 하기위해 (왜냐하면 나의 프로젝트는 ATM lpatfor때문이다) 이 팁을(http://www.arm.com/pdfs/DAI0034A_efficient_c.pdf) 사용하고있었다
그러나 나는 인터넷으로부터 많은 문서를 이용하였지만 모든팁이 나의 일에 도움이 되지 않았다
그래서 나는 매우 유용하고 효과적인 팁만을 모았다
또한 나는 조금수정된 ATM의 일부환경에 그들이 적용하고있는 그들의 방법을 가지고있다
내가 앞에서 언급한 것들은 PDF파일이며 단지 여러싸이트에서 단지 만들어진 자료를 수집했다
나는 이 자료를 혼자만의 발견이 아니다
이 정보의 references는 이글의 끝에 기제하겠다



어디서 필요한가?
이 포인트(문제제기)없이 토론은 시작될수없을것이다
최적화된 컴퓨터 프로그램에 대게 중요한 부분은 완벽하게 그 부분 또는 그 프로그램에서 천천히 실행되는 또는 메모리를 거대하게 잡아먹는 프로그램의 모듈을 찾아내는 것이다
만약 각각부분을 분리한다면 최적화된 그 다음 전체적으로 프로그램은 자동적으로 더 빨라질것이다

최적화는 프로그램이 가지고잇는 여러가지 내부루푸에서 되풀이 하여 불리워지는 이런 부분들이 끝난 다음에 할것이다

경험이 있는 프로그래머들을 위해 아주 쉽게 프로그램이 요구하는 대게 최적화된 어텐션(외부로부터 처리요구) 포인트를 찾아내는대 유용할것이다

그러나 그것들은 프로그램의 부분을 탐지하는대 많이 이용된다
나는 Bisual C+ IDE's를 이용해 프로그램이 낭비하는 부분을 찾는 비법을 이용할것이다

다른툴로써는 프로그램이 느린 부분을 찾아내기에 매우 탐지하기 좋은 intel Bten을 이용할것이다

it will usually be a particular inner or nested loop, or a call to some third party library methods, which is the main culprit for running the program slow.



정수(Integers)
우리는 우리가 쓰고있는 벨류가 음수가 아니라면 int형 대신 unsigend int형을 사용해야 한다
어떤 프로세서들은 unsinged integer의 연산을 signed연산(혼자 코드를 기록하기엔 좋은습관이다)보다 매우 빨리한다

그래서 int 벨류를 루프에 이용할경우 깔끔한 좋은선언 :

register ungigned int variable_name;

비록 이것이 어떤 컴파일러라도 레지스터에 알려 unsigned 는 아마 만들어질것이다
그러나 이것은 모든 컴파일러에 적합한 것은 아니다

기억하라, integer연산은 floating-point연산보다 빠르다
이것은 유용할것이다. 프로세스에 직접적으로 외부의 FPUs 또는 floating point 수학 라이버리에 의지하는것보다 오히려 더 빠를것이다

우리는 소수 둘째자리까지 정확할 필요가 있고 (예를들어 간단한 회계 거래), 모든것을 100배한 다음 가능한 늦게 floating point로 변환할 필요가 있다



나눗셈 그리고 나머지
표준 프로세서에서 분모와 분자의 32bit 나눗셈은 20~140의 실행싸이클을 가지고있다
나눗셈 함수는 각각의 bit를 분할할 일정한 시간이 더걸린다

Time (numerator / denominator) = C0 + C1* log2 (numerator / denominator)
= C0 + C1 * (log2 (numerator) - log2 (denominator)).

널리 쓰이는 버전은 약 20 + 4.3N 의 싸이클를 가지고있다
ARM프로세서가 이 좋지않은 연산은 피하는것이 바람직한다
가능하면 때때로 그런 표현은 곱셈에 의하여 나눗셈을 대체하여 다시 쓸수있다

예를들어 (a / b) > c 는 만약 b를 실제적이고[?] b*c가 integer범위안이라는것을 안다면 a > (c * b) 로 다시 쓰일수있다
이것은 unsigned 나눗셈은 하나의 오퍼랜드가 unsigned라는 책임을 진다면 이것은 signed 나눗셈보다 훨신더 빠를것이다



결합나눗셈 그리고 나머지
나눗셈 (x/y) 그리고 나머지(x%y)둘다 종종 필요한 케이스이다
그러한 케이스에 비추어보아 나눗셈펑션을 컴파일러에 결합하는것이좋다 왜냐하면 나눗셈펑션은 항상 나눈값과 나머지를 리턴하기 필요하다
만약둘다 필요하다면 우리는 이와같은 예제를 같이 쓸수있어야한다

int func_div_and_mod (int a, int b) {
return (a / b) + (a % b);
}



거듭제곱에 의한 나눗셈과 나머지
We can make a division more optimized if the divisor in a division operation is a power of two. The compiler uses a shift to perform the division. Therefore, we should always arrange, where possible, for scaling factors to be powers of two (for example, 64 rather than 66). And if it is unsigned, then it will be more faster than the signed division.



typedef unsigned int uint;

uint div32u (uint a) {
return a / 32;
}
int div32s (int a){
return a / 32;
}



나눗셈은 나눗셈 펑션이 불리워지는것을 피할것이다 그리고 unsigned 나눗셈은 signed 나눗셈보다 더 조금 불리워진다
signed 나눗셈은 더 많은 실행시간을 가질것이다 왜냐하면 이것은 슈프트 순환은 -무한대를 가르키는동안 0쪽으로 돌것이다



나머지연산을 위한 대안
우리는 나눗셈을 나머지연산 이용한다
그러나 이것은 때때로 문장을 체크할때 코드를 다시쓰게된다



이 두개의 예제를 잘 생각해보자



uint modulo_func1 (uint count)
{
return (++count % 60);
}



uint modulo_func2 (uint count)
{
if (++count >= 60)
count = 0;
return (count);
}

나눗셈보다는 if문이이 더 빠른 연산을 수행하기때문에 더 선호된된다. 또한 새로운 버전은 입력의 범위가 0-59일때만 작동한다는 것을 기억하라



배열의 인댁스 활용
만약 어떤 밸류에 의하여 특별한 변수를 선언하기를 희망한다면 이와같이 해라 :

switch ( queue ) {
case 0 : letter = 'W';
break;
case 1 : letter = 'S';
break;
case 2 : letter = 'U';
break;
}

if ( queue == 0 )
letter = 'W';
else if ( queue == 1 )
letter = 'S';
else
letter = 'U';


아래의 방법(더빠른)은 charater 배열의 인덱스를이용해 간단하게 이용할수있다 예 :
static char *classes="WSU"; letter = classesqueue;



전역 변수
전역변수는 절때 레지스터에 할당되지 않는다.
포인터를 사용하여 간접적으로 전역변수를 할당하거나 펑션 콜(함수호출)을 이용해서 전역변수를 변환할 수 있다

따라서, 컴파일러는 레지스터에 있는 전역 변수값을 캐시에 입력하지 못해, 그 결과 전역 변수를 이용하면 여분의 것(대개 불필요함)이 로드되고 저장된다.

우리는 그런까닭에 전역변수를 안쪽 루프에 써선안된다

어떠한 함수에서 전역 변수를 많이 이용한다면, 그러한 전역 변수를 지역 변수로 복사하는 것이 이득이므로 레지스터에 할당할 수 있다

이것은 유일하게 함수가 호출되었을때 전역변수는 사용되지 않는것이 가능하다

int f(void);
int g(void);
int errs;
void test1(void)
{
errs += f();
errs += g();
}
void test2(void)
{
int localerrs = errs;
localerrs += f();
localerrs += g();
errs = localerrs;
}


test1는 로드해야만한고 전역 errs벨류는 함수가 호출될때마다 증가된값이 저장되는것에 반해 test2의 localerrs는 레지스터에 저장하고 오직 하나의 명령만 저장된다는것에 주목하라


(별칭/가명)의 이용

Consider the following example -

void func1( int *data ) {
int i;
for(i=0; i<10; i++)
{
anyfunc( *data, i);
}
}

*data는 결코 바뀌지 않는다 해도 컴파일러는 anyfunc()가 그것을 변경하지 않았다는것을 알지 못하기때문에 그 프로그램은 이것이 사용될때마다 매번 메모리로부터 읽어들여야만 한다. -이것은 아마도 다른 어떤곳에서 변경되어지는 변수의 가칭일수도 있다. 만약 우리가 그것이 변경되어질수 없다는것을 안다면, 대신 우리는 그것을 이렇게 코딩할수 있을것이다

void func1( int *data )
{
int i;
int localdata;

localdata = *data;
for(i=0; i<10; i++)
{
anyfunc ( localdata, i);
}
}

이것은 컴파일러에게 보다 최적화될수있는 기회를 준다



Live variables 그리고 유출

어떤 프로세서는 일정한 레지스터들을 가지고있다, 거기에는 프로그램안에 레지스터들안에 다른 하나의 포인터를 가질수있다
어떤 컴파일러들은 빠른 live-range 지원해준다, 언제? 다른 레지스터에 변수뿐만아니라 메모리안에 다른함수의 부분에도 할당한다

변수의 live-range는 모든 문장 사이에 지정한 변수의 마지막 할당과 같이 정의한다 그리고 마지막 변수의 용법 전에 다음할당한다

이 정렬에서, 확실한 변수의 값은 확실하다, 따라서 이것은 사용이가능하다

live ranges사이에, 변수의 값는 필요하지 않다 : 이것은 죽엇다, 그래서 이 레지스터는 다른변수를 이용할수있고 컴파일러가 레지스터들의 보다많은 변수를 할당할수잇도록 허락해준다
몇몇의 레지스터들은 레지스터 할당변수는 가장작은 몇몇의 overlapping live-ranges at each point in a function 필요로한다
만약 이것을 넘게되면 몇몇의 레지스터들은 이용할수있게된다, 약간의 변수들은 임시메모리를 파괴해야만한다. 이것은 프로세서가 유출을 불러온다.

이 컴파일러의 유출은 아주조금 종종 변수의 처음에 익숙하다, 그래서 최소로 유출을 줄이는것이다. 변수의 유출은 다음을 피해야한다 :

*최대로 제한하는 몇몇의 살아잇는 변수 : 이 전형적으로 이루어지는 곁에 관리를 간단히 표시한다, 그리고 사용해서는 안되는 많은 함수안에 변수들.
나누어 큰 함수들은 더 작게, 간단한 것 또한 도움이 될지모른다.

*자주이용되는 변수들의 레지스터를 이용 : 이것은 레지스터 변수가 종종 이용되는 컴파일러 말한다, 그래서 이것은 레지스터를 할당하고 매우높은 우선순위 를 할수잇다. 그러나 그러한 변수들은 아마 조용한,얌전한 어떤환경에서 유출될것이다

번역한마디
억지도많고 이해가되지 않아서 억어지로 짜맞춘게 많아 ..
미안해 ㅤㅎㅛㅇ들 .. 좋은글 막 날로 먹으려고하고있어 ..
오래걸렸어 .. 적어도 일주일에 하나씩은 하려고 노력중이야 ..
앞으로는 좀더 그럴싸하게 할께 .. 미안해 ..
신고

'Programing > 최적화' 카테고리의 다른 글

C 코드 최적화  (2) 2008.07.21
C코드 최적화하기  (0) 2008.07.21
Posted by Frys

티스토리 툴바