OS/Linux

Linux scheduler의 time slice 가시화 ( + nice value)

:) :) 2024. 10. 18. 22:51
 

그림으로 배우는 리눅스 구조 | 타케우치 사토루 - 교보문고

그림으로 배우는 리눅스 구조 | 선배가 옆에서 하나하나 알려주듯 친절히 설명해주는 실습과 그림으로 배우는 리눅스 지식의 모든 것 * Go 언어와 Python, Bash 스크립트 실습 코드 제공 * 이 도서는

product.kyobobook.co.kr

 

책 "그림으로 배우는 리눅스 구조"의 3장 "프로세스 스케쥴러"의

발췌 및 정리와 제 생각을 담은 글입니다.

 

 

컨테이너 환경에서 편하게 실습할 수 있도록

실습 환경을 도커 이미지로 만들었습니다.

아래 깃허브에 실행 방법까지 적어뒀습니다.

 

GitHub - gyeo009/Setting-Up-a-Practice-Environment-with-Container-for-the-Book_TAMESHITE-RIKAI-LINUX-NO-SHIKUMI: 책 [그림으

책 [그림으로 배우는 리눅스 구조] 실습 환경 구성을 위한 도커 이미지 명세. Contribute to gyeo009/Setting-Up-a-Practice-Environment-with-Container-for-the-Book_TAMESHITE-RIKAI-LINUX-NO-SHIKUMI development by creating an account o.

github.com

 

 


 

시스템에 실행 가능한 프로세스가 여러 개 존재할 때

프로그램을 실행하려면

프로그램 코드를 프로세스 형태로 메모리에 올리고,

프로세스가 일정 시간 CPU를 점유해 연산을 진행합니다. 즉 프로세스의 할 일(코드)을 수행합니다.

 

그런데 CPU는 한정적입니다.

CPU는 한정적인데 CPU를 사용하고 싶은 프로세스가 여러 개라면 어떻게 해야 할까요?

 

CPU가 1개인 상황으로 가정하자면,

프로세스 하나가 다 처리될 때까지 다른 프로세스는 기다려야 하는 걸까요?

 

이러면 대기중인 나머지 프로세스는 모두 다 하염없이 대기해야 하는 걸까요...?

이렇게 되면 대기 순위가 마지막인 프로세스는 정말 많은 시간을 기다려야 할 겁니다(대기시간이 거의 지수함수가 되겠네요)...

 

이러한 상황을 막기 위해,

일단 모든 프로세스를 조금씩 조금씩이라도 수행할 수 있도록 합니다.

 

이때 필요한 게 리눅스 커널 기능인 프로세스 스케쥴러(앞으로는 스케줄러로 표기) 입니다.

 

 

스케쥴러

스케쥴러의 교과서적인 정의는 다음과 같아요.

  • 하나의 논리 CPU는 동시에 하나의 프로세스만 처리합니다.
  • 실행 가능한 여러 프로세스가 time slice 단위로 순서대로 CPU를 사용합니다.

CPU가 1개, 프로세스가 3개(P0, P1, P2)라면

P0는 time slice만큼 CPU를 사용하고,

그 다음 P1이 time slice 만큼 CPU를,

그 다음 P2가,

이후 다시 P0가 time slice 만큼 CPU를 사용하는 방식이에요.

 

이렇게 스케쥴링을 하는 걸 스케쥴러라고 합니다.

스케쥴링 방식에는 정말 여러가지가 있는데,

방금 서술한 방식은 우선순위 없이 똑같은 양을 분배해준다고 해서

Round-Robin, 즉 RR 스케쥴링이라고 합니다.

 

elapsed time, CPU time

이후 내용을 이해하려면 elapsed time(경과 시간) 과 CPU time(cpu 사용 시간)을 알아야 합니다.

  • elapsed time
    • 프로세스가 시작할 때부터 종료할 때까지 경과한 시간을 의미합니다.
    • timer를 작동해 시작부터 끝까지 측정한 시간입니다.
  • cpu time
    • 프로세스가 실제로 논리 CPU를 사용한 시간을 의미합니다.
    • cpu time은 user 과 sys로 또 나뉩니다.
      • user time은 프로세스가 사용자 공간에서 동작한 시간을 의미하고
      • sys time은 프로세스가 시스템 콜을 호출해 커널이 동작한 시간을 의미합니다.

 

 

실험을 통해서 확인해볼까요?

아래 time 명령을 사용하면

프로세스의 elapsed time과 cpu time(user, sys)를 알 수 있습니다.

time(1)

(bash의 명령이기 때문에 (1)이 붙었습니다.)

 

아래 loop.py 스크립트를 하나 생성해, 아무것도 안하고 루프만 뺑뺑 도는 프로그램을 만들어봅시다.

#!/usr/bin/python3

# 부하 정도를 조절하는 값
# time 명령을 사용해서 실행했을 때 몇 초 정도에 끝나도록 조절하면 결과를 확인하기 좋다.
NLOOP = 95000000 #보통 10^8로 하는데 저는 3초 맞추려고 9천5백만으로 했습니다.

for _ in range(NLOOP):
    pass

 

 

이후 아래 명령을 통해 경과 시간 및 CPU 사용 시간을 확인합니다.

$ time ./load.py

 

(각 시간은 실습 환경에 따라 다를 수 있습니다,)

여기서 real은 elapsed time, 즉 프로세스가 실행되고 종료될 때까지의 경과시간을 의미합니다.

user는 프로세스가 사용자 공간에서 동작한 시간,

sys는 프로세스에 의해 커널이 동작한 시간을 의미합니다(load.py 프로그램을 프로세스화 하면서 일정량의 시스템 콜을 호출합니다.)

 

 

이번에는 CPU를 거의 사용하지 않는 sleep 명령으로 실험해볼까요?

$ time sleep 3

 

결과가 어떻게 나올까요?

 

 

네, 보란듯이 elapsed time을 의미하는 real만 3초,

user는 하나도 찍히지 않았고,

sys에 조금 소요가 있네요(프로세스화 하는데 든 시간)

 

 

 

 

어라

근데 그러면 보통

real = user + sys 일거라는 생각이 들고,

위 처럼 sleep 하는 경우가 있을 경우 real > user+sys 이기 때문에

 

일반적으로

real >= user+sys 일거라고 생각이 드는데요

 

real < user + sys 인 경우도 존재합니다

언제일까요?

  • 측정 오차로 인해 real 보다 user+sys가 약간 더 클 수 있습니다
    • 너무 신경 쓸 필요는 없습니다.
  • 그런데 real보다 user+sys가 무시못할정도로 큰 경우가 있습니다
    • 왜 그럴까요?

 

사실 time 명령어로 얻은 user와 sys 값은

정보 확인 대상 프로세스 에 더해

해당 프로세스의 “종료된 자식 프로세스”의 값을 더한 값입니다.

 

따라서 어떤 프로세스가 자식 프로세스를 생성하고,

각각 다른 논리 CPU에서 동작해서

real보다 user + sys가 커질 수 있습니다!

 

 

 

예를 들어,

부모 프로세스의 user time이 2.998초, sys가 0.002 이고

자식 프로세스의 user time이 2.998초, sys가 0.002 인 프로그램이 있다고 해볼게요.

 

부모 프로세스는 CPU0에서 실행하고,

자식 프로세스는 CPU1에서 실행하면

서로 영향을 주지 않고 실행할 수 있기 때문에

 

동시에 실행이 가능합니다.

 

즉 전체 수행 시간이 6초인 것 처럼 보일수도 있지만

사실 CPU0, 1에 잘 분배해 실행했기 때문에 2배로 줄어들어

3초만에 전체과정을 수행할 수 있습니다.

따라서 time명령 수행시 real time = 3초로 나옵니다.

 

그러나 user time은 부모 프로세스의 user time 과 자식 프로세스의 user time의 합으로 계산되어 나오기 때문에

user time = 6초 로 찍힐 수도 있는거죠!

 

 

정리하자면,

어떤 프로세스가 자식 프로세스를 생성하고

각각 다른 논리 CPU에서 동작한다면 real < user+sys 가 될 수 있습니다.

 

 

 

 

자 그래서 time slice는

어떻게 분배될까요?

 

하나의 CPU에서 동시에 수행할 수 있는 프로세스 개수는 하나입니다.

그러나 구체적으로 어떻게 CPU 자원을 배분하고 있는지는 잘 안보입니다.

 

이번에는 python의 matplotlib 라이브러리를 이용해

time slice 별 CPU 점유 데이터를 그래프로 그려

시각화해서 보겠습니다.

 

아래 코드는 스케쥴러가 실행 가능한 프로세스에

time slice 단위로 CPU를 나눠주는 걸 시각화합니다.

 

#!/usr/bin/python3

##!/usr/bin/python3는 쉐뱅(Shebang) 또는 해시뱅(Hashbang)이라고 불리며, 스크립트 파일의 맨 첫 줄에 작성됩니다. 이 줄은 해당 스크립트가 실행될 때 사용할 인터프리터를 지정하는 역할을 합니다.

import sys
import time
import os
import plot_sched  # 그래프를 그리기 위한 사용자 정의 모듈을 import 합니다.

# 사용법 안내: 올바른 인자를 전달하지 않았을 때 도움말을 출력합니다.
def usage():
    print("""사용법: {} <프로세스 개수>
    * 논리 CPU0에서 <프로세스 개수> 만큼 동시에 100milli sec 동안 CPU 자원을 소비하는 부하 처리 프로세스를 기동하고 모든 프로세스 종료를 기다립니다.
    * 'sched-<프로세스 개수>.jpg' 파일에 실행 결과를 표시한 그래프를 저장합니다.
    * 그래프 x축은 부하 처리 프로세스의 경과 시간[밀리초], y축은 진척도[%]""".format(progname, file=sys.stderr))
    sys.exit(1)  # 인자 오류 시 프로그램 종료

# 부하를 측정하기 위한 반복 루프 수 설정
NLOOP_FOR_ESTIMATION = 100000000  # 부하 측정을 위해 실행할 루프 수
nloop_per_msec = None  # 1밀리초(msec) 동안 실행할 반복 횟수를 저장할 변수
progname = sys.argv[0]  # 프로그램 이름 저장 (명령줄 첫 번째 인자)

# 1밀리초 동안 실행할 반복 횟수를 추정하는 함수 (이후 이 값으로 반복)
def estimate_loops_per_msec():
    before = time.perf_counter()  # 시작 시간 측정
    for _ in range(NLOOP_FOR_ESTIMATION):
        pass  # 부하를 발생시키기 위해 루프 실행
    after = time.perf_counter()  # 종료 시간 측정
    return int(NLOOP_FOR_ESTIMATION / (after - before) / 1000)  # 1밀리초당 반복 횟수 계산

# 자식 프로세스에서 실행될 함수: 프로세스당 부하를 처리하며 결과를 파일로 저장
def child_fn(n):
    progress = 100 * [None]  # 100단계의 진척도를 저장할 리스트 초기화
    for i in range(100):  # 100번 반복 (100ms 동안 진행)
        for j in range(nloop_per_msec):
            pass  # CPU 부하를 주기 위해 반복 실행
        progress[i] = time.perf_counter()  # 매 단계의 시간 기록
        
    f = open("{}.data".format(n), "w")  # 결과를 파일로 저장하기 위해, 파일 오픈
    for i in range(100):
        f.write("{}\t{}\n".format((progress[i] - start) * 1000, i))  # 경과 시간과 진척도를 파일에 쓰기
    f.close()  # 파일 닫기
    exit(0)  # 자식 프로세스 종료


# 인자가 부족한 경우 사용법 출력
if len(sys.argv) < 2:
    usage()

# 동시 실행할 프로세스 수 (concurrency)를 명령줄 인자로부터 가져옴
concurrency = int(sys.argv[1])

# 동시 실행할 프로세스 수는 1 이상의 값이어야 함
if concurrency < 1:
    print("<동시 실행>은 1 이상의 정수를 사용합니다: {}".format(concurrency))
    usage()

# 강제로 논리 CPU0에서만 실행되도록 설정 (CPU Affinity 설정)
os.sched_setaffinity(0, {0})

# 부하를 측정할 반복 횟수 추정
nloop_per_msec = estimate_loops_per_msec()

# 모든 프로세스가 시작되는 시간을 기록
start = time.perf_counter()

# 동시 실행할 프로세스 개수만큼 자식 프로세스를 생성
for i in range(concurrency):
    pid = os.fork()  # 자식 프로세스를 생성
    if pid < 0:
        exit(1)  # 프로세스 생성 실패 시 종료
    elif pid == 0:
        child_fn(i)  # 자식 프로세스는 부하 처리 함수 실행

# 모든 자식 프로세스가 종료될 때까지 대기
for i in range(concurrency):
    os.wait()

# plot_sched 모듈을 이용하여 그래프를 생성(다른 파일)
plot_sched.plot_sched(concurrency)

 

  • 맨 첫줄의 " #!/usr/bin/python3 "
    • 이는 Shebang, 즉 쉐뱅이라고 하며 python interpreter를 명시해, 명령창에 파일 경로만 입력해도 파이썬 프로그램으로 실행 가능하도록 합니다.
  •  time.perf_counter()
    • Python에서 정확한 시간 측정을 위해 사용하는 함수입니다. 이 함수는 프로세스 시작 후 경과한 시간을 초 단위로 반환합니다.
  • usage() 함수
    • 프로그램에 인자를 잘 못 넣었을 때 호출하며, 올바른 사용법을 안내해주고 프로그램을 종료하는 함수입니다.
  • estimate_loops_per_msec()
    • `NLOOP_FOR_ESTIMATION` 만큼 루프를 실행하여 1ms 동안 몇 번의 루프를 돌릴 수 있는지 추정합니다. 부하를 측정하기 위한 전처리 단계입니다.
      • 각 컴퓨터의 성능에 맞게, 1ms 간 실행할 수 있을 만큼의 loop 수를 측정하는 것입니다.
  • child_fn()
    • 각 자식 프로세스가 100ms 동안 부하를 발생시키고, 그 경과 시간을 저장한 후 `{프로세스 번호}.data` 파일에 기록합니다.
      • 데이터를 기입할 파일이 각 프로세스마다 다르기 때문에,
        • 동기화같은 건 생각 안해도 됩니다.
  • Set CPU Affinity
    • SMP 시스템 상에서 프로세스를 주어진 CPU 세트에 제한(bound)할 수 있는 스케쥴러 속성입니다.
    • 프로세스를 특정 CPU에 묶을 수 있습니다.
      • SMP는 나중에 배웁니다.
    • 여기서는 os.sched_setaffinity(0, {0}) 를 사용하여 모든 프로세스가 논리 CPU 0에서만 실행되도록 설정합니다.
  • 이후 plot.sched 스크립트의 plot_sched() 함수를 사용해 결과를 그래프로 출력합니다.

 

 

 

가시화(시각화)

결과 출력 코드는 작성하지 않겠습니다.

데이터 파일과 matplotlib 라이브러리를 잘 사용하면 바로 출력 가능합니다.

 

다만 ubuntu20.04와 matplotlib 사이에 버그가 존재해 일단 png 파일로 저장한 후 jpg 형식으로 변환하는게 안전합니다.

 

결과는 다음과 같습니다.

 

 

동시 실행 프로세스가 1개일 때

프로세스 하나만 실행중이며, 따라서 CPU0를 하나의 프로세스만 점유합니다.

 

처리기(여기서는 CPU)를 프로세스 하나가 독점적으로 점유하기 때문에,

시간에 따른 진척도가 연속된 선형 추세를 보임을 알 수 있습니다.

 

 

 

 

 

 

동시 실행 프로세스가 2개일 때

CPU0를 두 개의 프로세스가 공유합니다.

 

부하 처리0과 1은 프로세스 0과 1의 처리과정을 의미합니다.

(앞으로 프로세스 n은 pn으로 표기)

 

스케쥴링 규칙에 따라 다르겠지만,

우선 제 컴퓨터에서는 p1을 먼저 처리를 하는 것을 알 수 있습니다.

 

여기서 중요한 건, p1(노란색)을 모두 처리한 이후에 p0(파란색)을 처리하는게 아니라는 것입니다.

p1을 조금 처리하다가, p0을 조금 처리하고(비슷한 양)

이후 p1, p0, .... 이렇게 처리하는 것을 알 수 있네요.

 

 

그래프는 선들이 불연속적으로 존재하는 것으로 보이는데,

따라서 불연속 시점에서 CPU가 일을 하지 않나? 라고 오해할 수도 있을 것 같습니다.

 

그러나 CPU는 지속적으로 일을 하는게 맞습니다.

점 간격에 의한 착시효과 정도로 이해하시면 좋을 것 같습니다.

 

 

 

동시 실행 프로세스가 3개일 때

프로세스 3개가 CPU0를 공유하고 있다

 

이번에도 역시 공평하게 리소스를 나눠가져가는 것을 볼 수 있네요

 

그러나 아래 빨간색 동그라미 친 부분을 보면 알 수 있 듯

각 루프 당 진척도를 정~말 동일하게 보장하지는 않는 걸 볼 수 있습니다

아마 context switch가 빈번하게 일어나면서 생기는 오차처럼 보입니다.

 

 

 

 

 

10개일 때

 

위에서 오차가 생기는 것을 보고

많은 프로세스를 실행하면 어떻게 더 난잡해지지 않을까 싶었는데요,

그래도 알록달록 편안한 그림이 잘 나왔습니다.

 

OS가 스케쥴링을 안정적으로 하도록 잘 실행하는 것 같습니다.

 

 

 

time slice 구조

그런데 위의 결과를 보면 알 수 있듯

동시에 실행(하고싶은)할 프로세스 개수를 늘일수록

 

각 프로세스에 할당된 time slice가 짧다는 것을 알 수 있습니다.

 

이는 리눅스 스케쥴러의 스케쥴링 규칙에 의한 현상입니다.

리눅스 스케쥴러는 sysctl의 kernel.sched_latency_ns 파라미터에 지정한

targeted latency, 즉 목표 레이턴시 간격에 한 번씩 CPU 시간을 얻을 수 있습니다.

 

보통 /sys/kernel/debug/sched/latency_ns 파일을 확인하면 알 수 있으나,

docker container 환경에서는 보안상의 이유로 컨테이너의 /sys/kernel/debug 디렉토리에 접근할 수 없습니다.

 

접근하고 싶다면 컨테이너를 생성할 때 --privileged 플래그를 붙여 더 높은 권한을 주면 되지만,

저는 보안상의 이유로 확인하지 않으려 합니다.

 

일반적으로 kernel.sched_latency_ns 값은 24000000ns로, 24ms 정도 되는 것 같습니다.

 

 

이 때, 각 프로세스의 time slice는

kernel.sched_latency_ns / <논리 CPU에 running 혹은 ready 상태의 프로세스 개수> [ns]
입니다.

 

즉 CPU를 하나만 사용하는 환경에서 프로세스 2개를 실행하려 할 때,

목표 레이턴시(targeted latency)가 24ms 이기 때문에

24/2 = 12 이어서

각 프로세스는 12ms 씩 CPU를 점유할 수 있는 것입니다.

 

아래 그림을 다시 볼까요?

CPU0를 두 개의 프로세스가 공유합니다.

 

그래프를 보면, 약 24ms 간격으로 p0와 p1 실행이 반복되는 것을 알 수 있고(목표 레이턴시가 24ms 이기 때문에)

그 속에서 12ms씩 p0와 p1를 실행하는 것을 확인할 수 있네요!

 

신기합니다.

 

여기까지 보면 프로세스마다 할당되는 time slice는 모두 공평하게

고정된 값인 것처럼 보입니다.

 

그러나! 여러 이유로 time slice는 프로세스마다 동등하게 할당되지 않습니다.

 

 

time slice는 고정값이 아니다

리눅스 커널 v2.6.23 이전의 스케쥴러는 time slice가 고정값(100ms) 이었는데,

소프트웨어의 발전으로 프로세스 개수가 늘어남에 따라

각 프로세스가 CPU 점유 시간이 돌아올 때까지 더 오랫동안 기다려야 하는 현상이 발생했습니다.

 

이러한 현상을 Convoy effect라고 하는데요!

OS 수업에서 한 번쯤 들어본 용어입니다.

 

예전 리눅스 스케쥴러가 RR 방식을 택하고 있기 때문에 저런 Convoy effect가 발생한겁니다.

 

따라서 이러한 현상을 해결하기 위해,

현대의 리눅스 커널은 CFS(Completely Fair Scheduler)와 같은

동적 스케줄링 알고리즘을 사용하여 time slice를 조정합니다.

즉 time slice를 동적으로 변경할 수 있습니다.

 

CFS는 프로세스의 우선순위와 대기 시간을 기반으로

CPU 점유 시간을 조정하여 Convoy Effect를 최소화합니다.

 

time slice는 다음과 같은 조건에 의해 변화합니다.

  • 시스템에 존재하는 논리 CPU 개수
  • 일정한 값을 넘은 논리 CPU에서 실행 중(running)/실행 대기(ready) 중인 프로세스 개수
  • nice value

 

마지막 조건이 조금 생소한데요,

여기서 nice value를 한 번 알아볼까요?

 

 

 

 

 

nice 값을 조절해 프로세스 우선도를 변경할 수 있다!

nice value란, 프로세스의 우선도를 뜻하는 -20 ~ +19 사이의 값입니다.

프로세스 생성시 default nice value가 0입니다.

-20은 최우선으로 실행해야 한다는 의미이고,

+19는 가장 우선도가 낮은 프로세스임을 의미합니다.

 

중요한 건,

우선도는 누구나 낮출 수 있지만(값을 증가시키는 과정)

우선도를 높이는 건(값을 낮추기) root 권한을 가진 사용자 뿐입니다.

 

nice value는

nice(1), renice(1) 과 같은 사용자 명령,

nice(2) 및 setpriority(2) 같은 시스템 콜 등으로 변경 가능합니다.

 

결과적으로 스케쥴러는 nice value가 낮은, 그래서 우선도가 높은 프로세스에

time slice를 더 많이 부여합니다.

 

 

 

 

 

 

 

한번 실험으로 확인해볼까요?

nice value가 다른 프로세스 2개 동시 실행

아까와 같은 실험 환경에서 코드를 일부분 수정합니다.

두 번째 입력 인자로 nice value를 받을 수 있도록 합니다.

 

프로세스0 과 프로세스 1을 생성할 때, 프로세스 1만 nice value를 nice_val 만큼 증가하도록 합니다. os.nice 함수는 현재 프로세스의 nice value를 내부 인자값만큼 증가시켜주는 함수입니다.

 

 

이후 인자에 5를 추가로 넣어,

p0는 nice value가 default value인 0으로,

p1은 5로 지정한 후 프로그램을 돌리면,

 

 

짜잔!

두 프로세스의 실행이 전혀 공평하지 않게 나왔습니다.

예상대로, p0이 p1보다 time slice를 많이 가져간 것을 알 수 있습니다!

 

 

 

 

 

 

 

번외) sar 명령 속 nice field의 의미

sar 명령은 System Activity Report의 약자로,

Linux System에서 시스템 성능 및 리소스 사용 현황을 모니터링하고 보고하는 데 사용되는 유틸리티입니다.

 

CPU, Memory, I/O Device, Network traffic 등의

다양한 성능 지표를 실시간 또는 특정 시간 간격으로 수집하고 분석할 수 있습니다.

 

이러한 명령에는 %nice라는 필드가 존재하는데요,

 

여기서의 nice는 nice value가 기본값인 0보다 커진 프로세스가

사용자 모드로 실행된 시간의 비율을 나타냅니다!

 

%user field는 nice value가 0인 채 실행된 비율을 의미합니다!

 

 

 

 

 

 

 

위는 무한 루프를 도는 파이썬 프로그램에 nice 값을 5 추가해 백그라운드로 돌린 이후,

sar 명령으로 통계를 CPU0을(-P 0) 1초 간격으로 1번만 보겠다는 명령을 입력한 결과입니다.

 

보시면 의도한 대로 nice의 비율이 크게 늘어난 것을 확인할 수 있네요!

 

 

 

 

 

결론

CPU(처리기)는 하나인데, 처리해야할 것(프로세스)들이 여러개라면

하나의 CPU를 공평하게 나눠서 쓰는 경우도 있지만

convoy effect를 피하기 위해

Linux 스케쥴러는 CFS라는 스케쥴러를 사용하고,

 

그 과정에서 nice value라는 것도 사용해 우선도를 지정합니다!

 

재밌는 건

targeted_latency 도 파일 형태로 관리하고 있다는 것을 알게되었다는 거네요!

보통 24ms였습니다!

 

 

 

더 알아보기

Linux Scheduler와 관련한 내용은 POSIX 표준이 아니기에,

커널 버전이 바뀌면 변할 수 있습니다.

따라서 항상 다음 링크를 참고해서 최신 내용을 확인하면 좋을 것 같아요!

 

 

Scheduler — The Linux Kernel documentation

 

www.kernel.org

Scheduler - The Linux Kernel documentation