Innovating today, leading tomorrow

OpenSQL_Internals
[OpenSQL] PostgreSQL의 Parallel Query (1)

[OpenSQL] PostgreSQL의 Parallel Query (1)

Parallel Query는 다수의 CPU를 활용하여 보다 빠르게 쿼리에 대한 답변을 주기 위한 질의 처리 방법입니다. Parallel Query Overview에서는 Parallel Query의 개념에 대하여 간략하게 설명하고, Sequential Scan을 Parallel Query로 수행하는 상황을 예시로 전반적인 과정을 살펴보겠습니다.

Parallel Execution Model

다음은 PostgreSQL 공식 문서의 Parallel Query 예시입니다. 이번 장에서는 다음 예시를 산정하고 과정에 대하여 설명하도록 하겠습니다.

EXPLAIN SELECT * FROM pgbench_accounts WHERE filler LIKE ‘%x%’;
QUERY PLAN
———————————————————————————————————————————–

Gather (cost=1000.00..217018.43 rows=1 width=97)
Workers Planned: 2
-> Parallel Seq Scan on pgbench_accounts (cost=0.00..216018.33 rows=1 width=97)
Filter: (filler ~~ ‘%x%’::text)
(4 rows)

Reference.

Plan

이번 문단에서는 Parallel Query를 수행하기 위한 실행 계획에 대하여 설명하겠습니다. 위의 예시를 살펴보면 Gather 노드 하단에 Seq Scan이 자식 노드로 존재하고, 일반적인 실행 계획과는 다르게 Workers Planned: 2가 명시되어 있음을 확인할 수 있습니다.

이제 공식 문서의 정의되어 있는 다음 3가지 인용구를 기반으로 Parallel Query 실행 계획이 어떻게 구성되어 있는지 확인해보겠습니다.

Gather or Gather Merge

When the optimizer determines that parallel query is the fastest execution strategy for a particular query, it will create a query plan that includes a Gather or Gather Merge node.

Parallel Query 수행을 위한 실행 계획은 항상 Gather 또는 Gather Merge 노드 생성을 통해 이루어진다는 사실을 설명하고 있습니다.

Exactly 1 Child Plan

In all cases, the Gather or Gather Merge node will have exactly one child plan, which is the portion of the plan that will be executed in parallel.

Gather 또는 Gather Merge 노드는 항상 1개의 자식 플랜을 가지고 있다고 설명하고 있습니다. 자식 플랜이란 부모 노드를 기준으로 Callstack에 쌓이게 되는 노드의 집합인데, Parallel Query에서의 경우 Gather 또는 Gather Merge 노드의 자식 플랜은 항상 하나이며 (즉, Gather의 자식 플랜 안에 또 다른 Gather가 들어갈 수 없다는 뜻입니다) 자식 플랜은 병렬로 처리되게 됩니다. 만약 Gather 또는 Gather Merge 노드가 전체 플랜의 최상단에 위치한다면, 전체 플랜을 병렬로 처리하게 됩니다.

Worker Nodes

When the Gather node is reached during query execution, the process that is implementing the user’s session will request a number of background worker process equal to the number of workers chosen by the planner.

typedef struct Gather
{
Plan plan;
int num_workers;
int rescan_param;
bool single_copy;
bool invisible;
Bitmapset *initParam;
} Gather;

typedef struct GatherMerge
{
Plan plan;
int num_workers;
int rescan_param;
int numCols;
AttrNumber *sortColIdx pg_node_attr(array_size(numCols));
Oid *sortOperators pg_node_attr(array_size(numCols));
Oid *collations pg_node_attr(array_size(numCols));
bool *nullsFirst pg_node_attr(array_size(numCols));
Bitmapset *initParam;
} GatherMerge;

위의 설명과 Gather, Gather Merge 노드의 코드 상 정의를 통해 Planner는 몇 개의 Worker로 Parallel Query를 수행할 지 결정하고 해당 정보는 Gather 또는 Gather Merge 노드에 기록됨을 확인할 수 있습니다.

Reference.

Workers

Parallel Query는 병렬로 처리를 위해 여러 Background Worker Process(이하 Worker)로 작업을 분산합니다. 사용자 Query를 수행 중인 Worker Process(Leader Worker)가 Plan의 Gather 노드를 만나면, Gather 노드의 num_workers 값 만큼의 Background Worker Process 할당을 요청합니다. Leader Worker도 Background Worker와 함께 Gather 노드의 자식 플랜을 수행하는 역할을 하지만, 추가적으로 다른 Background Worker가 생성한 튜플을 읽는 작업도 병행해야 합니다. 따라서, 자식 플랜에서 생성하는 튜플 양이 적은 경우에는 Leader Worker는 마치 또 하나의 Background Worker처럼 튜플을 만들어내는 데에 시간을 많이 할애하게 되지만, 반대로 생성하는 튜플 양이 많은 경우에는 Leader Worker가 생성된 튜플을 읽고 Gather 노드 상위 플랜 수행에 모든 시간을 할애하게 되며 자식 플랜 수행은 하지 못 하게 됩니다.

When the parallel portion of the plan generates only a small number of tuples, the leader will often behave very much like an additional worker, speeding up query execution. Conversely, when the parallel portion of the plan generates a large number of tuples, the leader may be almost entirely occupied with reading the tuples generated by the workers and performing any further processing steps that are required by plan nodes above the level of the Gather node or Gather Merge node.

  • PostgreSQL 공식 Document –

Gather Merge는 각 Background Worker가 튜플을 정렬된 순서로 생성하고 있고 이 순서를 보존해야 하는 경우 플랜에 생성되는데, 이 때 Leader Worker는 정렬 순서를 보전하며 병합 작업을 진행합니다. 반대로 일반 Merge의 경우 순서를 보존할 필요 없이 생성되는 순서대로 병합을 하기 때문에 Background Worker가 튜플을 정렬하여 생성했더라도 정렬 순서는 무시됩니다.

하단의 그림은 Gather – Parallel Seq Scan (Workers Planned: 3) 상황에서 어떻게 플랜을 각 Worker에서 수행하고 테이블을 읽어오는지 도식화한 그림입니다.

Reference.

Parameter

본격적인 내용에 들어가기에 앞서 가볍게 Parallel Query를 위한 파라미터를 살펴보겠습니다.

  • max_parallel_workers_per_gather (Default: 2): 하나의 Gather에서 사용 가능한 최대 Worker의 수를 설정합니다.
  • max_worker_processes (Default: 8): 동시에 실행 가능한 최대 Background Process의 수를 설정합니다.
  • max_parallel_workers (Default: 8): 병렬 쿼리 실행 시, 사용 가능한 최대 Worker의 수를 설정합니다.

Reference.

Parallel Query 수행 과정

이번 문단에서는 PostgreSQL이 Parallel Query를 수행하게 되는 과정에 대하여 설명하겠습니다. PostgreSQL은 Parallel Query 수행까지 다음 3가지 과정을 거치게 됩니다.

Consider Parallel Query

Path를 생성하기 전, Parallel로 수행하는 Path를 고려할 지 결정하는 단계입니다. 해당 단계에서 consider_parallel = true로 설정되어 Parallel Query를 고려하기로 결정한다면 PostgreSQL은 Parallel Query 수행을 위한 Path를 생성하게 됩니다.

Planning

생성된 Path 중 Parallel을 사용하는 Gather 혹은 Gather Merge가 최적의 Path로 결정되면 이에 대한 플랜을 생성합니다. 수행에 사용할 Worker의 수를 결정하는 과정 또한 Planner에서 진행됩니다.

Execute

Executor는 전달 받은 Parallel Query 플랜을 통해 적절한 Worker를 생성하고 각 Worker에 작업을 분배하여 해당 작업을 수행하도록 합니다.

이제 우리는 Sequential Scan을 수행하는 Gather 노드를 산정하고 각 단계의 과정을 코드 레벨에서 살펴볼 것입니다. 이번 장에서는 Consider Parallel Query와 Planner 단계의 수행 과정을 살펴보도록 하겠습니다.

Consider Parallel Query

이전 포스팅에서 저희는 플랜을 생성하기 전 여러 수행 전략(Path)에 대한 Cost를 계산하고 이를 기반으로 가장 최적의 Path를 선택한다는 사실을 언급한 바 있습니다. 그러나, PostgreSQL는 항상 Parallel Query를 사용하기 위한 Path를 고려하지는 않습니다. 이번 항목에서는 PostgreSQL이 언제, 어떤 과정을 거쳐 Parallel Query를 수행하기 위한 Path의 생성 여부를 결정하는지 살펴보도록 하겠습니다.

가능한 모든 Path를 탐색하는 make_one_rel 함수부터 시작해보도록 하겠습니다. 해당 함수는 RelOptInfo 구조체를 반환하며, 쿼리 수행을 위해 가능한 모든 Path를 탐색합니다. make_one_rel 함수는 초반에 set_base_rel_sizes 함수를 호출합니다. 해당 함수에서는 Parallel Query 사용 여부를 의미하는 parallelModeOK가 true일 경우 set_rel_consider_parallel 함수를 호출합니다.

set_rel_consider_parallel 함수에서 Parallel Query Path를 고려할 지 여부를 결정합니다. 다음은 해당 함수의 구현부 입니다.

static void
set_rel_consider_parallel(PlannerInfo *root, RelOptInfo *rel,
RangeTblEntry *rte)
{

case RTE_FUNCTION:
if (!is_parallel_safe(root, (Node *) rte->functions))
return;
break;

/* We have a winner. */
rel->consider_parallel = true;
}

순서를 조금 바꿔서 해당 함수의 마지막 부분을 먼저 살펴보도록 하겠습니다. 해당 함수에 마지막 부분에는 Parallel Query를 사용하기 위한 Path를 찾는 것을 고려하도록 consider_parallel = true; 를 수행하도록 되어 있습니다. 그리고 주석으로 / We have a winner / 라는 문구가 작성되어 있습니다. 여기서 우리는 생략된 부분에 Parallel Query를 고려하지 않도록 하는 수 많은 조건이 작성되어 있고, 모든 조건을 헤치고 나서야 비로소 consider_parallel을 true로 설정하는 과정으로 진행된다는 사실을 유추할 수 있습니다.

생략된 구간에는 해당 함수를 종료하는 (Parallel Query 수행을 위한 Path 탐색을 고려하지 않도록 하는) 여러 조건들이 구현되어 있습니다. 설명을 위해 조건을 검사하는 하나의 코드 살펴보겠습니다. 해당 코드에서는 is_parallel_safe를 수행하여 false가 반환될 경우, return을 수행하여 마지막 코드에 도달하지 못하도록 하고 있습니다. 그렇지 않으면, break를 수행하여 다음 조건에 대한 검사를 수행할 것입니다.

Reference.

Planning

이전 항목에서는 PostgreSQL이 언제 Parallel Query를 위한 Path를 고려하는지 결정하는 과정에 대하여 설명하였습니다. 이번 장에서는 여러 Path들 중 Best Path가 Parallel Query를 사용하는 전략에 해당하는 Gather로 결정되었음을 가정하고 어떤 과정을 거쳐 Executor에 넘겨주기 위한 Plan을 생성하는 지에 대하여 설명하겠습니다.

Planner는 결정된 최적 Path를 기반으로 Executor가 Query를 수행하기 위한 플랜을 작성하는 일을 수행합니다. Planner는 create_plan(planner_info, best_path) 함수를 호출하고, 해당 함수는 create_plan_recurse 함수를 호출하여 재귀로 플랜 트리를 생성합니다.

각 path는 타입을 속성으로 가지고 있습니다. 그리고 create_plan_recurse는 현재 path의 속성이 T_Gather, T_GatherMerge일 경우, create_gather_plan 또는 create_gather_merge_plan 함수를 호출하여 Parallel 수행을 위한 Plan 노드를 생성을 진행합니다.

다음 코드는 해당 과정을 정의하고 있는 구현부입니다.

static Plan *
create_plan_recurse(PlannerInfo *root, Path *best_path, int flags)
{
Plan *plan;

switch (best_path->pathtype)
{

case T_Gather:
plan = (Plan *) create_gather_plan(root,
(GatherPath *) best_path);
break;

case T_GatherMerge:
plan = (Plan *) create_gather_merge_plan(root,
(GatherMergePath *) best_path);
break;

}
return plan;
}

우리는 Gather를 예시로 산정하였으므로 create_gather_plan이 호출되었다고 가정하고 이후 과정을 살펴보겠습니다. Gather 노드, Sub Plan에 해당하는 Seq Scan 노드, 그리고 Worker의 개수가 어떻게 작성되는 지를 중점적으로 확인할 것입니다.

다음 그림은 create_gather_plan 함수에서 위에 언급한 사항들이 어떻게 작성되는지 표시한 그림입니다.

여기서 우리는 쿼리의 해당 부분이 병렬(Gather)로 수행될 것이며, 두 개의 Worker가 Sequential Scan을 수행할 것이라는 정보를 가지고 있는 Gather 플랜 노드를 생성하였음을 확인할 수 있습니다.

이제 Executor는 해당 내용을 기반으로 병렬 수행을 진행할 것입니다. 그리고 해당 내용은 본 주제의 다음 장에서 설명하도록 하겠습니다.

Reference.

Parallel Safety

잠깐! 해당 장을 끝마치기 전, Parallel Safety에 대해 추가로 살펴봅시다.

병렬 수행은 쿼리를 처리하는 속도를 빠르게 향상 시켜주지만 모든 작업을 수행할 수 있는 것은 아닙니다. 이에 대한 내용을 정의하는 개념이 Parallel Safety 입니다.

PostgreSQL은 각 Plan Operation에 대해 Parallel Safety를 아래와 같이 정의합니다.

  • Parallel Safe: Parallel Query 수행에 문제가 없는 Operation입니다.
  • Parallel Restricted: Background Worker에게 나눠서 줄 수 없고 Leader Worker가 혼자서 수행해야 하는 Operation입니다.
    • 따라서 Parallel Query가 수행될 때 Parallel Restricted한 Operation은 Gather 노드의 자식 플랜에서 수행되면 안 되고 꼭 플랜의 다른 위치에서 수행되어야 함
  • Parallel Unsafe: 병렬로 수행할 수 없는 작업입니다. 해당 작업이 포함되어 있으면 병렬로 수행되지 않습니다. PostgreSQL은 아래와 같은 작업들을 Parallel Unsafe 하다고 명시적으로 언급하고 있습니다.
    • Common Table Expression(CTE)를 Scan하는 노드
    • 임시 테이블을 Scan하는 노드
    • IsForeignScanParallelSafe가 명시되지 않은 Foreign Scan 노드
    • Subquery를 수행해야 하는 노드인 경우 (Correlated 여부 상관 없이)
      • Correlated Parameter를 받는 Subquery인 경우 SubPlan이 노드에 생성됨
      • Correlated Parameter가 없는 경우에는 InitPlan이 노드에 생성됨
      • SubPlan 또는 InitPlan 상관 없이 Subquery 수행하는 노드는 Parallel Unsafe함

typedef struct Plan
{

bool parallel_aware; /* engage parallel-aware logic? / bool parallel_safe; / OK to use as part of parallel plan? */

}

다음 코드를 살펴보면 각 플랜에는 parallel_safe 여부를 정의하는 boolean을 정의되어 있음을 확인할 수 있습니다. 이제 해당 값이 어디에서 지정되는지 살펴보도록 하겠습니다.

Parallel Unsafe 처리

사실 parallel_safe 값은 룰이 다양한 만큼 여러 부분에서 설정하고 있습니다. 일례로 MinMaxAgg의 경우 InitPlan을 포함하고 있어 명시적으로 Parallel Unsafe한 작업입니다. 우리는 다음 코드를 통해 이에 명시적으로 Parallel Unsafe한 작업에 대한 처리를 수행하는 지 확인해보도록 하겠습니다.

MinMaxAggPath *
create_minmaxagg_path(PlannerInfo *root,
RelOptInfo *rel,
PathTarget *target,
List *mmaggregates,
List *quals)
{
MinMaxAggPath *pathnode = makeNode(MinMaxAggPath);

pathnode->path.parallel_safe = false;
pathnode->path.parallel_workers = 0;

return pathnode;
}

위 코드와 같이 명시적으로 해당 path의 parallel_safe 값을 false로 설정하고, 추가로 parallel_workers도 0으로 설정해주는 것을 확인할 수 있습니다. 이로써 해당 Path는 병렬로 수행되지 않을 것 입니다.

Parallel Safety 체크

위에서는 명시적으로 Parallel 수행이 불가능한 작업에 대한 PostgreSQL의 구현을 살펴보았습니다. 이번 항목에서는 주어진 쿼리의 Parallel Safety Level(Worst Case)을 반환하는 동작에 대하여 살펴보겠습니다. 하단의 코드는 Planner의 동작을 정의하고 있는 코드입니다.

PlannedStmt *
standard_planner(Query *parse, const char *query_string, int cursorOptions,
ParamListInfo boundParams)
{

if ((cursorOptions & CURSOR_OPT_PARALLEL_OK) != 0 &&
IsUnderPostmaster &&
parse->commandType == CMD_SELECT &&
!parse->hasModifyingCTE &&
max_parallel_workers_per_gather > 0 &&
!IsParallelWorker())
{
/* all the cheap tests pass, so scan the query tree / glob->maxParallelHazard = max_parallel_hazard(parse); glob->parallelModeOK = (glob->maxParallelHazard != PROPARALLEL_UNSAFE); } else { / skip the query tree scan, just assume it’s unsafe */
glob->maxParallelHazard = PROPARALLEL_UNSAFE;
glob->parallelModeOK = false;
}

우리가 확인할 부분은 max_parallel_hazard를 호출하는 부분입니다. max_parallel_hazard는 Query Tree을 입력으로 받아 Parallel Query 수행 시 최악의 경우를 반환하는 함수입니다. 해당 코드에서는 최악의 경우가 Parallel Unsafe 하지 않을 때, parallelModeOK를 true로 설정하는 것을 확인할 수 있습니다.

또한, 해당 검사를 수행하지 않았을 경우에는 Default로 Parallel Query를 수행하지 않도록 설정합니다.

마치며

이번 장에서는 쿼리를 병렬로 수행하기 위한 판단 과정과 이에 대한 플랜을 작성하는 과정에 대하여 설명하였습니다. 해당 주제의 다음 장에서는 실제 작성된 플랜을 바탕으로 Executor에서 어떤 과정을 통해 수행하는 지 설명할 것 입니다. 또한, 추가로 Parallel Safe, Parallel Restricted를 설명하도록 하겠습니다.

지금까지 PostgreSQL의 Parallel Query (1)에 관해 알아보았습니다

PostgreSQL의 Parallel Query (2)을 바로 이어서 확인해보세요!

광고성 정보 수신

개인정보 수집, 활용 목적 및 기간

(주)티맥스티베로의 개인정보 수집 및 이용 목적은 다음과 같습니다.
내용을 자세히 읽어보신 후 동의 여부를 결정해 주시기 바랍니다.

  • 수집 목적: 티맥스티베로 뉴스레터 발송 및 고객 관리
  • 수집 항목: 성함, 회사명, 회사 이메일, 연락처, 부서명, 직급, 산업, 담당업무, 관계사 여부, 방문 경로
  • 보유 및 이용 기간: 동의 철회 시까지

※ 위 개인정보 수집 및 이용에 대한 동의를 거부할 권리가 있습니다.
※ 필수 수집 항목에 대한 동의를 거부하는 경우 뉴스레터 구독이 제한될 수 있습니다.

개인정보의 처리 위탁 정보
  • 업체명: 스티비 주식회사
  • 위탁 업무 목적 및 범위: 광고가 포함된 뉴스레터 발송 및 수신자 관리
 

개인정보 수집 및 이용

개인정보 수집, 활용 목적 및 기간

(주)티맥스티베로의 개인정보 수집 및 이용 목적은 다음과 같습니다. 내용을 자세히 읽어보신 후 동의 여부를 결정해 주시기 바랍니다.

  • 수집 목적: 티맥스티베로 뉴스레터 발송 및 고객 관리
  • 수집 항목: 성함, 회사명, 회사 이메일, 연락처, 부서명, 직급, 산업, 담당업무, 관계사 여부, 방문 경로
  • 보유 및 이용 기간: 동의 철회 시까지

※ 위 개인정보 수집 및 이용에 대한 동의를 거부할 권리가 있습니다.
※ 필수 수집 항목에 대한 동의를 거부하는 경우 뉴스레터 구독이 제한될 수 있습니다.

개인정보의 처리 위탁 정보

  • 업체명: 스티비 주식회사
  • 위탁 업무 목적 및 범위: 광고가 포함된 뉴스레터 발송 및 수신자 관리
  •