ONNX Runtime은 모델을 inference 하기 위해 Session을 생성해야 하는데 그 전에 몇가지 선행사항이 있다. 그 선행사항 중 하나가 바로 Ort::Env 를 설정하는 일이다.

Ort::Env 의 목적

객체 생성자는 다음의 것을 사용했다. 다른 생성자에 대한 정보는 이 포스트 멘 아래에 제시된 링크에서 찾아볼 수 있다. 하지만 대부분의 경우 함수형 말고는 어떠한 정보도 얻을 수 없을 것이다. 대신 C++ document는 아니지만 공식 문서는 github에서 찾을 수 있다. onnx runtime github document

Ort::Env (OrtLoggingLevel logging_level=ORT_LOGGING_LEVEL_WARNING, const char *logid="");

생성자를 통해 유추하면 Ort::Env 는 logging level을 정하는 용도로 사용할 수 있는 것 같다. Ort::Env 는 Session이 유지되는 동안 해제되면 안되므로 같은 스코프에 생성해 유지하자. 생성은 다음과 같이 이루어진다.

Ort::Env env{ORT_LOGGING_LEVEL_WARNING, "test env"};

이전에 언급한바 API doc이 없는 것과 같다. 전혀 어떠한 설명도 없다. 다만 성능을 위해서 logging level 정도는 조절해야 하지 않겠는가.

공식 API doc에 몇 안되는 설명을 가져와봤다.

- ORT_LOGGING_LEVEL_VERBOSE
  Verbose informational messages (least severe)

- ORT_LOGGING_LEVEL_INFO
  Informational messages.

- ORT_LOGGING_LEVEL_WARNING
  Warning messages.

- ORT_LOGGING_LEVEL_ERROR
  Error messages.

- ORT_LOGGING_LEVEL_FATAL
  Fatal error messages (most severe)

위에서 설명한 생성자 이외에도 몇가지가 더 있고 또 메소드도 있지만 그것은 차후에 아래에 적도록 하겠다.

Ort::SessionOptions 설정

Ort::SessionOptions 는 세션에 대한 다양한 설정을 제공한다.

생성자는 인자가 없다. 그러므로 생성은 다음과 같다.

Ort::SessionOptions session_options;

Ort::Session_Options 의 메소드는 모두 자신의 reference를 리턴하기 때문에 버전 체인 형태로 사용할 수 있다.

Provider

가장 관심 가는 부분은 아래의 Provider 부분이다.

SessionOptions &
AppendExecutionProvider_CUDA(const OrtCUDAProviderOptions &provider_options);

SessionOptions &
AppendExecutionProvider_ROCM(const OrtROCMProviderOptions &provider_options);

SessionOptions &
AppendExecutionProvider_OpenVINO(const OrtOpenVINOProviderOptions &provider_options);

SessionOptions &
AppendExecutionProvider_TensorRT(const OrtTensorRTProviderOptions &provider_options);

하지만 위의 부분은 이 포스트에서 다루지 않는다. 왜냐하면 우리는 지금 CPU로도 inference하지 못하는 중이기 때문이다. GPU를 이용하는 부분은 차후에 다룬다.

많은 설정이 있지만 다음의 것만 간략하게 알아보자.

Max Thread Number

SessionOptions & SetIntraOpNumThreads(int intra_op_num_threads);
SessionOptions & SetInterOpNumThreads(int inter_op_num_threads);

일단 이름에서 유추할 수 있듯이 위의 두 메소드는 연산을 수행하는 최대 thread 수를 정하는데 연관있다. 중요한 것은 intra operation thread number와 inter operation thread number의 관계이다. intra operation thread number는 하나의 operation 내부에서 수행하는 thread 수를 말하고 inter operation thread number는 서로 다른 operation을 수행하는 thread 수를 말한다.

ONNX Runtime github doc

위의 페이지에는 다음과 같은 설명이 나온다.

사용하는 ONNX Runtime이 OpenMP를 사용해 빌드된 경우, intra op thread number를 지정하지 마라 만약 ONNX Runtime이 OpenMP를 사용해 빌드되지 않은 경우, 적절한 intra op thread number를 지정해라 inter op num threads (parallel execution이 사용 설정된 경우에만 의미를 가진다.)는 OpenMP에 영향받지 않으므로 항상 적절한 값을 필요로 한다.

즉 사용하고 있는 ONNX Runtime이 OpenMP를 사용하여 빌드된 경우 intra operation thread number는 건드리지 말고 inter operation thread number만 설정해 주면 된다는 말이다. inter operation thread number도 직접 돌려보면서 최적의 값을 찾는 것이 좋아보인다.

ONNX Runtime 홈페이지에서 OpenMP 사용시 설정할 수 있는 옵션 중에 2가지를 추천해서 가져와 봤다.

export OMP_NUM_THREADS=n
export OMP_WAIT_POLICY=PASSIVE/ACTIVE

보다시피 bash에서 환경변수로 설정해줘야 한다.

OpenMP는 ONNXRuntime 1.8.0 버전 부터 deprecate 되었다.

Graph Optimization Level

SessionOptions &
SetGraphOptimizationLevel(GraphOptimizationLevel graph_optimization_model);

이 메소드는 inference시 사용할 최적화의 정도를 지정하는데 사용된다. 아래의 레벨 중에 하나를 골라 사용하면 된다.

  • ORT_DISABLE_ALL
  • ORT_ENABLE_BASIC
  • ORT_ENABLE_EXTENDED
  • ORT_ENABLE_ALL

개인적인 생각으로 사용가능한 모든 최적화를 사용하고 싶어서 ORT_ENABLE_ALL을 사용했다. (아마 이 값이 default인 것으로 알고 있다.)

Save Optimized Model

모델을 로드하는 시간을 줄이는 것이 중요하다면 SessionOption에 Optimize를 설정하고 다음의 메소드를 호출하자.

SessionOptions &
SetOptimizedModelFilePath(const char *optimized_model_file);

위 메소드를 호출하면 ONNX Runtime이 최적화한 모델을 전달한 경로에 저장한다. 이 과정을 거친 후에 다시 로드하면 최적화하는데 소모되는 CPU 자원과 시간을 아낄 수 있다.

Profiling on / off

SessionOptions & EnableProfiling(const char *profile_file_prefix);
SessionOptions & DisableProfiling();

세션의 성능 파악을 위해 데이터를 기록한다. ONNX Runtime으로 세션을 profiling하기 위해서는 위의 EnableProfiling 옵션을 켜고 inference한다. Session이 종료된 이후에 인자로 전달한 profile\_file\_prefix 폴더에 json 파일이 생성되는데 이를 이용해 inference 과정을 분석할 수 있다. MS Edge나 Google Chrome을 켜고 edge://tracing 혹은 chrome://tracing으로 들어가서 생성된 json 파일을 로드하면 일반적인 웹브라우져의 프로파일 기능을 사용해 분석할 수 있다.

Execution Mode

SessionOptions & SetExecutionMode(ExecutionMode execution_mode);

이 옵션은 inter operation 간에 threading을 사용할 것인지 설정하는 것이다. 다음의 옵션 중 하나를 전달하면 된다.

  • ORT_SEQUENTIAL
  • ORT_PARALLEL

여기까지 대략적인 세션 옵션에 대해 적었다. 원래 ONNX Runtime C++ API는 C API의 랩퍼인 관계로 여기에 명시되지 않은 옵션들 중에는 C API를 직접사용 해야하는 경우도 있다. 그런 경우는 C API 특유의 문제를 정확히 파악하고 사용하기 바란다.

Ort::Session 설정하기

Ort::Session 은 inference하기 위한 거의 마지막 작업이다. Session은 모델을 로드하고 tensor를 입력 받아 inference한다.

이전 포스트에서 다뤘던 Ort::Env, Ort::SessionOptions 와 model path를 인자로 받는다. 특히 Ort::Env 의 경우 Session과 생명주기가 같아야 한다. 즉 Env가 소멸되지 않아야 Session을 사용할 수 있다.

Session 생성

이전 포스트에서 다룬 객체를 초기화했다면 다음의 생성자를 이용해 Session을 생성할 수 있다.

Ort::Session(Ort::Env&, const char *model_path, const Ort::SessionOptions&);

이 생성자는 모델 파일을 불러오지만 메모리 상에 존재하는 모델을 이용하는 생성자도 있다. 필요하면 차후에 작성하도록 하겠다. (2022-01-29)

Model 정보 얻기

필요한 Model의 정보는 다음과 같다.

  • input/output의 개수
  • input/output의 모양
  • input/output의 이름
  • input/output의 type
  1. input/output의 개수

    size_t input_number = session.GetInputCount();
    size_t output_number = session.GetOutputCount();
    

    매우 간단하게 사용가능하다.

  2. input/output의 이름

    Ort::AllocatorWithDefaultOptions allocator;
    
    // 0번째 input의 이름
    const char *input_name = session.GetInputName(0, allocator);
    // 0번째 output의 이름
    const char *output_name = session.GetOutputName(0, allocator);
    
    // C가 생각나는 끔찍한 방식의 메모리 해제
    allocator.Free((void*) input_name);
    allocator.Free((void*) output_name);
    

    allocator로 할당한 이름을 Free로 해제하는 이 끔찍한 상황은 ONNX Runtime C++ API가 C wrapper이기 때문에 발생한 문제이다.

  3. input/output의 type, shape

    Ort::TypeInfo input_type_info = session.GetInputTypeInfo(0); // 0번째 입력에 대한 type 정보
    Ort::TensorTypeAndShapeInfo input_tensor_info = input_type_info.GetTensorTypeAndShapeInfo();
    

    이렇게 모델이 요구하는 type에 대한 정보를 알아낼 수 있다. Ort::TypeInfo 는 이것 이외에도 다른 정보도 제공하지만 생략하겠다.

    ONNXTensorElementDataType elem_type = input_tensor_info.GetElementType();
    
    if (ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT == elem_type)
      {
        std::cout << "tensor which consists of float!\n";
      }
    

    위의 방법으로 요구하는 입력의 자료형을 알아낼 수 있다.

    ONNXTensorElementDataType의 종류는 다음과 같다.(enum ONNXTensorElementDataType)

    • ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT8
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_INT8
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT16
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_INT16
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_STRING
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_BOOL
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_DOUBLE
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT32
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT64
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX64
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX128
    • ONNX_TENSOR_ELEMENT_DATA_TYPE_BFLOAT16

    다만 지원하지 않는 type이 끼여 있는데 complex64, complex128은 지원하지 않는다고 한다.

    std::vector<int64_t> input_shape = input_tensor_info.GetShape();
    

    이렇게 tensor의 모양까지 알아낼 수 있었다. output의 경우 처음 과정에 GetOutputInfo 를 사용하면 된다.

이제 남은 부분은 inference다! inference는 다음과 같이 진행된다.

  1. 전처리
  2. tensor 생성
  3. inference
  4. 결과 사용

Inference 하기

Inference Step 1 - Allocate Input/Output Buffers

현재 사용가능한 정보들은 input/output shape, input/output 개수 등이다. 이 정보를 이용해 input/output에 해당하는 buffer를 메모리에 할당하고 Ort::Value 라는 객체에 저장해서 Ort::Session::Run 메소드에 전달해야 한다. 특히 input의 경우 전처리된 이미지 등의 값을 연속적인 메모리에 저장한 값이다.

Ort::Value 를 생성하기 위해서는 Ort::MemoryInfo 를 생성해야 한다. Ort::MemoryInfo 는 우리가 사용하는 버퍼 메모리에 대한 정보이다.

auto allocator_info = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator, OrtMemTypeDefault);

Ort::MemoryInfo::CreateCpu 의 첫번째 인자는 allocator의 종류인데 다음의 종류가 있다.

  • OrtInvalidAllocator
  • OrtDeviceAllocator
  • OrtArenaAllocator

두번째 인자는 우리가 사용한 allocator가 할당하는 memory의 종류인데 우리는 CPU inference하는 중이므로 OrtMemTypeDefault를 사용하면 된다. Memory 종류는 다음의 종류가 있다.

  • OrtMemTypeCPUInput CPU가 아닌 Execution Provider (cudnn 등)이 사용하는 CPU Memory
  • OrtMemTypeCPUOutput CPU가 아닌 Execution Provider가 생성한 CPU 접근가능한 Memory (i.e. CUDA_PINNED)
  • OrtMemTypeCPU 일시적으로 CPU가 접근가능한 Execution Provider가 할당한 Memory (i.e. CUDA_PINNED)
  • OrtMemTypeDefault Execution Provider가 사용하는 default 할당자

나중에 GPU를 사용하게 되면 CUDA_PINNED 메모리를 활용해 최적화할 수 있을 것이다.

아래의 예시는 Resnet18 모델과 같이 단일 입력, 단일 출력인 모델의 경우 Ort::Value 를 생성하는 예시이다. Ort::Value 는 메모리의 포인터를 빌린다. 그러므로 Ort::Value 는 메모리 해제를 책임지지 않는다.

Ort::Value input = Ort::CreateValue(allocator_info, input_vec.data(), input_vec.size(),
                                    input_shape.data(), input_shape.size());

Ort::Value output = Ort::CreateValue(allocator_info, output_vec.data(), output_vec.size(),
                                     output_shape.data(), output_shape.size());

만약 자신이 사용하고자하는 모델의 입력이나 출력이 복수인 경우 ```Ort::Value```를 연속적인 메모리에 저장할 수 있는 방법을 사용해야 한다.

std::vector<Ort::Value> inputs;
std::vector<Ort::Value> outputs;

Inference Step 2 – Inference

이제 inference할 준비가 모두 끝났다. 이전 포스트에서 초기화한 session은 Run이라는 메소드를 이용해서 inference한다.

session.Run(Ort::RunOptions{nullptr}, input_name, &input, 1, output_name, &output, 1);

혹은 다수의 입출력인 경우

session.Run(Ort::RunOptions{nullptr}, input_names, inputs.data(), inputs.size(),
            output_names, outputs.data(), outputs.size());

위의 과정이 종료되면 outputs에는 모델의 inference한 결과가 담기게 된다. 정확하게는 Ort::Value 생성시 넘겨준 buffer에 결과가 담긴다. 이 포스트에서 다룬 내용은 CPU inference를 주제로 하기때문에 Ort::IoBinding 에 대해서는 다루지 않는다.