리뷰할 Simulator는 POSTECH PSAL에서 만든 M2NDP의 Cycle level simulator이다.
https://github.com/PSAL-POSTECH/M2NDP-public?tab=readme-ov-file
M2NDP가 뭔지 궁금한 사람들은 아래 논문 링크를 참고해 읽어보도록 하자.
https://arxiv.org/abs/2404.19381
해당 논문에서 주장하는 low overhead GP NDP in CXL Memory를 실험하기 위해 직접 Simulator를 구현한 것이다.
해당 simulator의 특징은 크게 3가지이다.
1. NDP kernel operation이 정상적으로 이루어지는지 결과를 확인하는 FuncSim
2. 정상적인 동작을 하는지 확인한 NDP Kernel을 cycle level에서 latency, bandwidth utilization rate 확인을 위한 NDPSim
3. 해당 M2NDP를 위한 Complier가 없기 때문에 직접 RISC-V Vector extension을 이용해 만든 assembly NDP kernel
이렇게 3가지라고 볼 수 있다.
FuncSim
아무런 정보 없이 simulator를 해석하는 것보다 Top down 방식으로 해석하는 방법을 선택했다.

해당 instruction을 보면 결국 Funsim에서 알맞은 configuration file의 path를 입력해 주는 식으로 simulator가 동작한다.
따라서 functional_runner dir의 main.cc부터 해석을 진행했고

main.cc 의 일부를 보면 입력받은 M2NDP Configurtaion정보를 통해 num_ndp, ndp_units 등을 생성하고 trace file에서 얻은 memory_map 정보, kernel_id 등을 할당한다.
그 후에 받아온 정보에 맞게 ndp_unit을 run 시키는 것을 볼 수 있다.(해당 사진은 Single Kernel Mode)
다음으로 그럼 run 함수의 동작을 알아보자

run 함수의 경우 ndp_unit.cc file에서 호출된 것이다. 본 사진에서의 run 함수 내부 핵심 동작을 살펴보면
1. 입력받은 Kernel info에 맞게 ndp_unit 내의 sub_core들을 만든다.
2. Configuration 에서 설정한 공간을 넘지 않는 범위에서 ScratchPad 공간을 할당한다.
3.base address에 맞게 packet단위로 data를 저장한다.
3가지 정도를 확인할 수 있다.
해당 3가지 사전준비가 끝나면 Kernel을 실행시켜야 한다.

kernel을 실행시킬 때 첫 실행에서만 m_sub_core_unit에서 initializer를 호출해서 initializer 실행 후
target addr에 해당하는 kernel body실행하며, 마지막엔 finalizer를 실행시켜 마무리하는 것을 확인할 수 있다.
그리고 finalizer를 호출해 실행을 마무리하면 할당했던 scratchpad memory를 free 시켜 resource 낭비를 없애는 것을 확인할 수 있다.
다음으로 각각 executeKernel~ 함수들 동작을 확인해 보자

Sub_core.cc에서 execute 함수를 확인할 수 있고
각 Execute- 함수에서 instruction들을 RISC-V Vector extension format에 맞게 register_unit.cc에 있는 Convert함수로 Convert 진행,
최종적으로 ExecuteInsts_Array 함수를 통해 instruction 실행한다.
ExecuteInst_Array 함수를 확인해 보면

ExecuteInsts_Array의 경우 ndp_instruction.cc에 있는 Execute함수를 호출해 context 내용을 기반으로 instruction을 실행하는 것을 알 수 있다. 그러다가 Branch를 만나면 따로 빼서 for루프의 i를 처리하는 식으로 동작한다.

Execute함수에서는 각각 instruction의 opcode를 보고 해당하는 execution을 호출하고

가장 많이 사용하는 ExecuteVector를 예시로 확인해 보면 opcode를 보고 opcode에 맞는 동작을 실행하도록 되어있는 것을 확인할 수 있다.

마지막으로 기존 workload에 있는 SpMV Kernel을 Funcsim을 통해 simulation을 진행해 보면 정상적으로 동작하는 것을 확인할 수 있다.
NDPSim
timing simulation인 NDPSim 또한 동일하게 Top down 방식으로 분석을 진행했다.
FuncSim과 동일하게 main.cc부터 분석을 진행했다.

workload 종류에 따라 simulator runner가 달라지지만 일반적으로 사용하는 SimulationRunner를 분석하기로 결정.

SimulationRunner의 생성자에서 객체 생성과 동시에 trace file을 parsing + 입력에 맞게 config를 생성하는 것을 확인할 수 있다.
그다음 중간에 있는 parse_ndp_trace() 함수 동작을 확인해 보면,

parse_ndp_trace() 함수에서는
1. path에서 NDP Kernel목록 읽어옴
2. 시뮬레이션 설정에 따라 첫 번째와 마지막 kernel memory map 초기화함
3. launch 정보를 읽어 NDPCommand 객체 생성하고 command 추가함
의 동작을 하는 것을 알 수 있다.
그리고 결국 main함수에서 runner.run()을 호출하기 때문에 run() 함수를 확인해 보면,

run()에서는
1. check_single_simulation_finished 함수를 써서 단일 명령이 끝났는지 계속 확인한다.
2. m_m2 ndp_config, m_cxl_link, m_m2 ndp에서 cycle을 호출해 cycle 단위로 simulation을 진행한다.
3. 만약 끝나고, command가 존재한다면 새로운 command를 불러오며 만약 command가 is_barrier command라면 Synchronous 동작이기 때문에 Synchronous동작을 수행 + new kernel을 reigst 가능하면 kernel regist
4. process_memory_access() 함수를 통해 memory request를 관리
정도의 기능을 수행한다고 볼 수 있다.
다음으로 launch_ndp_kernel(command)에서 동작을 확인.

코드에서 보이는 것처럼 register_ndp_Kerenl 함수를 실행시켜 동작중 따라서 해당 코드를 확인해 보면

unit 안에 있는 모든 sub-core에도 kernel 등록 중임을 확인할 수 있다.
따라서 ndp_unit.cc에서 실제 함수 동작을 확인해 보면

instruction_buffer와 uthread_generator에 kernel 등록하는 것을 알 수 있고, register_kernel에서 어떤 동작을 하는지 확인해 보면

M2NDP논문에서 주장했던 dynamic 한 register 할당을 확인할 수 있다.(= 필요한 만큼의 register만 동적으로 할당함)
kernel 등록 과정을 확인해 보았으니 cycle() 함수를 통해 어떻게 cycle level로 시뮬레이션했는지 확인한다.
먼저 m_m2 ndp_config → cycle() 함수 확인한다.

m_m2ndp_config → cycle() 함수의 경우 해당 cycle() 함수를 통해 현재 cycle에서 어떤 domain이 활성화되어있는지 계하고 mask를 통해 활성화 되어 있는 domain만 활성화시킬 수 있게 만든다.

그 후 차례로 m_cxl_link → cycle()을 통해 cxl cycle인지 확인해서 동작시키고 m_m2 ndp → cycle()을 통해 추가 내부 동작 확인한다.
그중 먼저 m_cxl_link → cycle()을 확인해 보면,

Cxl_link cycle()에서는 host인지 m2ndp인지, 어디서 어디로의 요청인지에 따라 어디로 buffer에 CXL_Link로 data를 보낼 것인지 동작시키고, + log_interval로 CXL_Link Util을 확인한다.
다음으로 M2NDP cycle을 확인해보면

M2NDP cycel()에서는 각각 현재 domain이 어딘지에 따라 어떤 동작을 수행해야 하는지 구분되어 있고,
1. is_cache_cycle에서는 cache cycle이 맞으면 cache 동작을 하러 간 것이고,
2. buffer cycle이라면 buffer에 저장된 request에 맞게 NDP to ICNT, ICNT to NDP , Memory to ICNT , ICNT to Memory 등 동작을 모든 case에 대해 진행 후
3. ndp unit cycle 동작한다.
그리고 다음으로 중간에 buffer_ndp_Cycle()에서 ndpunit에서도 cycle() 호출되기 때문에 확인해 보면.

finished_context 확인, WB처리, MEM access 처리(cache, tlb 등) Load store unit cycle과 ldst unit, ndp unit 연결 처리 등을 순서대로 처리하고.

그다음으로 sub_core_unit의 cycle 단위 동작을 확인해야 한다.

sub_Core에서는 execute_instruction, i0 cache cycle 등등의 기능을 cycle()마다 수행한다.
아래에 호출되는 execute_instruction함수를 확인해 어떻게 명령어를 실행하는지 확인해 보자

execute_instruction에서 execution_unit을 할당해서 issue를 발행하고 issue발행 성공 실패 여부를 기록한다.
따라서 m_execution_unit → issue를 발행하기 때문에 issue() 함수 확인해야 한다.

해당 issue함수에서는 명령어들 간의 dependency문제가 발생하는지 check 하고,

instruction별로 연산 지연시간을 체크한다.

그리고 Kernel등록하며 할당받은 memory에서 해당 Instruction을 수행하기 위해 메모리를 준비한다.

마지막으로 특정 condition (functional_sim일 때 등등.. )에서만 바로 execution 함수를 통해 동작하고, branch operation이 아닐 경우에만 pc값을 바로 하나 증가시키는 것을 확인할 수 있다.
마지막으로 이런 명령어 동작을 cycle level로 수행하게 하는 건 ExecutionUnit::cycle()에서 확인할 수 있다.

해당 부분은 branch, 즉 분기 명령어가 들어왔을 때 unit별로 cycle() 증가시키고 결과를 확인할 수 있을 때 결과별로 처리하는 내용을 보여주고 있다.

다음으로 해당 부분은 ALU 관련 연산을 Cycle() 단위로 처리하는 부분임 process_default_execution_units 함수의 경우 operation이 끝나면 finish임을 알리고 register를 free로 만들어 자원을 fully utilize 하게 만드는 것을 알 수 있다.

마지막으로 memory 접근할 때 address계산 관련한 명령어를 처리하고 memory 관한 queue에 넣는 동작을 수행하는 부분임을 알 수 있다.
최종적으로 해당 NDPSim의 동작을 큰 틀에서나마 분석해 봤다. 간단하게 요약해 보자면 큰 틀(NDP Unit)에서 커널 등록 후 cycle() 호출을 하면 내부적으로 물려있는 더 작은 단위(NDP_Sub_core) 등에서 필요한 요소들의 cycle() 함수를 실행시키면서 cycle() level에서 simulator가 동작한다고 해석할 수 있을 것 같다.
마지막으로 NDPSim이 정상적으로 동작하는지 확인해 보면

정상적으로 잘 동작하는 것을 확인할 수 있다.
※ 틀린 내용이 있을 수 있으니 감안하고 봐주시길 바랍니다. ※