이 페이지에서는 효율적인 Bazel 규칙 작성의 구체적인 문제와 과제를 개략적으로 설명합니다.
요약 요구사항
- 가정: 정확성, 처리량, 사용 편의성, 지연 시간 목표
- 가정: 대규모 저장소
- 가정: BUILD와 유사한 설명 언어
- 기록: 로드, 분석, 실행 간의 하드 분리는 오래되었지만 여전히 API에 영향을 미침
- 내재적: 원격 실행 및 캐싱은 어려움
- 내장: 변경 정보를 사용하여 올바르고 빠른 증분 빌드를 실행하려면 비정상적인 코딩 패턴이 필요함
- 내재적: 2차 시간 및 메모리 소비 방지하기는 어려움
가정
다음은 정확성, 사용 편의성, 처리량, 대규모 저장소의 필요성과 같은 빌드 시스템에 관해 가정된 사항입니다. 다음 섹션에서는 이러한 가정을 다루고 규칙을 효과적으로 작성하기 위한 가이드라인을 제공합니다.
정확성, 처리량, 사용 편의성, 지연 시간 목표
빌드 시스템은 증분 빌드와 관련하여 무엇보다 정확해야 합니다. 지정된 소스 트리의 경우 출력 트리의 모양과 관계없이 동일한 빌드의 출력은 항상 동일해야 합니다. 대략적으로 말하면 Bazel은 입력이 변경될 경우 해당 단계를 다시 실행할 수 있도록 특정 빌드 단계에 사용되는 모든 단일 입력을 알아야 합니다. Bazel은 빌드 날짜 / 시간과 같은 일부 정보를 누출하고 파일 속성 변경과 같은 특정 유형의 변경사항을 무시하므로 정확도에 한계가 있습니다. 샌드박싱은 선언되지 않은 입력 파일에 대한 읽기를 방지하여 정확성을 보장하는 데 도움이 됩니다. 시스템의 내재된 제한 외에도 몇 가지 알려진 정확성 문제가 있으며 대부분 Fileset 또는 C++ 규칙과 관련되어 있습니다. 둘 다 어려운 문제입니다. 이 문제를 해결하기 위해 장기적으로 노력하고 있습니다.
빌드 시스템의 두 번째 목표는 높은 처리량을 갖는 것입니다. Google은 원격 실행 서비스의 현재 머신 할당 내에서 할 수 있는 작업의 경계를 영구적으로 푸시하고 있습니다. 원격 실행 서비스에 과부하가 걸리면 아무도 작업을 완료할 수 없습니다.
다음은 사용 편의성입니다. 원격 실행 서비스의 설치 공간이 동일하거나 유사한 여러 올바른 접근 방식 중에서 사용하기 더 쉬운 방식을 선택합니다.
지연 시간은 빌드를 시작한 후 의도한 결과를 얻을 때까지 걸리는 시간을 나타냅니다. 의도한 결과는 통과 또는 실패 테스트의 테스트 로그이거나 BUILD
파일에 오타가 있다는 오류 메시지일 수 있습니다.
이러한 목표는 종종 중복됩니다. 지연 시간은 사용 편의성과 관련된 정확성만큼 원격 실행 서비스의 처리량에 따라 달라집니다.
대규모 저장소
빌드 시스템은 대규모 저장소의 규모로 작동해야 합니다. 여기서 대규모는 단일 하드 드라이브에 맞지 않음을 의미하므로 사실상 모든 개발자 머신에서 전체 체크아웃을 할 수 없습니다. 중간 크기 빌드는 수만 개의 BUILD
파일을 읽고 파싱해야 하며 수십만 개의 glob을 평가해야 합니다. 이론적으로는 단일 머신에서 모든 BUILD
파일을 읽을 수 있지만 아직 적절한 시간과 메모리 내에서 그렇게 할 수는 없습니다. 따라서 BUILD
파일을 독립적으로 로드하고 파싱할 수 있어야 합니다.
BUILD와 유사한 설명 언어
여기서는 라이브러리 및 바이너리 규칙과 상호 종속 항목 선언에서 BUILD
파일과 대략 유사한 구성 언어를 가정합니다. BUILD
파일은 독립적으로 읽고 파싱할 수 있으며, 존재 여부를 제외하고 가능한 한 소스 파일을 살펴보지 않습니다.
고어
Bazel 버전 간에는 문제가 되는 차이점이 있으며 이러한 차이점 중 일부는 다음 섹션에 설명되어 있습니다.
로딩, 분석, 실행 간의 엄격한 분리는 오래되었지만 여전히 API에 영향을 미침
기술적으로는 작업이 원격 실행으로 전송되기 직전에 규칙이 작업의 입력 및 출력 파일을 알면 충분합니다. 하지만 원래 Bazel 코드 베이스에서는 패키지 로드, 구성 (기본적으로 명령줄 플래그)을 사용한 규칙 분석, 작업 실행이 엄격하게 분리되어 있었습니다. 이 구분은 Bazel의 핵심이 더 이상 필요하지 않더라도 (자세한 내용은 아래 참고) 오늘날에도 규칙 API의 일부입니다.
즉, 규칙 API에는 규칙 인터페이스에 대한 선언적 설명 (속성, 속성 유형)이 필요합니다. API에서 로드 단계 중에 맞춤 코드를 실행하여 출력 파일의 암시적 이름과 속성의 암시적 값을 계산할 수 있는 몇 가지 예외가 있습니다. 예를 들어 'foo'라는 java_library 규칙은 'libfoo.jar'라는 출력을 암시적으로 생성하며, 이 출력은 빌드 그래프의 다른 규칙에서 참조할 수 있습니다.
또한 규칙 분석에서는 소스 파일을 읽거나 작업의 출력을 검사할 수 없습니다. 대신 규칙 자체와 종속 항목에서만 결정되는 빌드 단계와 출력 파일 이름의 부분 방향 이분 그래프를 생성해야 합니다.
내적
규칙 작성을 어렵게 만드는 몇 가지 내재적 속성이 있으며 가장 일반적인 속성은 다음 섹션에 설명되어 있습니다.
원격 실행 및 캐싱은 어렵습니다.
원격 실행 및 캐싱은 단일 머신에서 빌드를 실행하는 것과 비교하여 대규모 저장소의 빌드 시간을 대략 2배 개선합니다. 하지만 실행해야 하는 규모는 엄청납니다. Google의 원격 실행 서비스는 초당 엄청난 수의 요청을 처리하도록 설계되었으며, 프로토콜은 서비스 측에서 불필요한 왕복과 불필요한 작업을 신중하게 방지합니다.
이때 프로토콜에서는 빌드 시스템이 주어진 작업의 모든 입력을 미리 알아야 합니다. 그러면 빌드 시스템이 고유한 작업 지문을 계산하고 스케줄러에 캐시 적중을 요청합니다. 캐시 적중이 발견되면 스케줄러는 출력 파일의 다이제스트로 응답합니다. 파일 자체는 나중에 다이제스트로 처리됩니다. 하지만 이렇게 하면 모든 입력 파일을 미리 선언해야 하는 Bazel 규칙에 제한이 적용됩니다.
정확하고 빠른 증분 빌드를 위해 변경 정보를 사용하려면 특이한 코딩 패턴이 필요합니다.
위에서 Bazel이 올바르려면 빌드 단계에 들어가는 모든 입력 파일을 알아야 해당 빌드 단계가 여전히 최신인지 감지할 수 있다고 설명했습니다. 패키지 로드 및 규칙 분석도 마찬가지이며, 이를 일반적으로 처리하도록 Skyframe이 설계되었습니다. Skyframe은 목표 노드 (예: '이 옵션으로 //foo 빌드')를 가져와 구성요소로 분류한 다음 평가하고 결합하여 이 결과를 산출하는 그래프 라이브러리이자 평가 프레임워크입니다. 이 프로세스의 일환으로 Skyframe은 패키지를 읽고, 규칙을 분석하고, 작업을 실행합니다.
각 노드에서 Skyframe은 목표 노드에서 입력 파일 (Skyframe 노드이기도 함)까지 특정 노드가 자체 출력을 계산하는 데 사용한 노드를 정확하게 추적합니다. 이 그래프를 메모리에 명시적으로 표현하면 빌드 시스템에서 입력 파일의 특정 변경사항 (입력 파일 생성 또는 삭제 포함)으로 인해 영향을 받는 노드를 정확하게 식별하여 출력 트리를 의도한 상태로 복원하는 데 필요한 최소한의 작업을 실행할 수 있습니다.
이 과정에서 각 노드는 종속 항목 검색 프로세스를 실행합니다. 각 노드는 종속 항목을 선언한 다음 이러한 종속 항목의 콘텐츠를 사용하여 더 많은 종속 항목을 선언할 수 있습니다. 원칙적으로 이는 노드당 스레드 모델에 잘 매핑됩니다. 하지만 중간 크기 빌드에는 수십만 개의 Skyframe 노드가 포함되어 있으며, 이는 현재 Java 기술로는 쉽지 않습니다 (그리고 역사적인 이유로 현재 Java 사용에 묶여 있으므로 경량 스레드와 연속이 없음).
대신 Bazel은 고정 크기 스레드 풀을 사용합니다. 하지만 이는 노드가 아직 사용할 수 없는 종속 항목을 선언하는 경우 종속 항목을 사용할 수 있을 때 평가를 중단하고 다시 시작해야 할 수 있음을 의미합니다 (다른 스레드에서 가능). 이는 노드가 이를 과도하게 수행해서는 안 된다는 의미입니다. N개의 종속 항목을 순차적으로 선언하는 노드는 잠재적으로 N번 다시 시작될 수 있으며 O(N^2) 시간이 소요됩니다. 대신 종속 항목을 미리 일괄 선언하는 것을 목표로 하며, 이를 위해서는 코드를 재구성하거나 노드를 여러 노드로 분할하여 다시 시작하는 횟수를 제한해야 하는 경우도 있습니다.
이 기술은 현재 규칙 API에서 사용할 수 없습니다. 대신 규칙 API는 로드, 분석, 실행 단계의 기존 개념을 사용하여 정의됩니다. 하지만 다른 노드에 대한 모든 액세스는 프레임워크를 통해 이루어져야 프레임워크가 해당 종속 항목을 추적할 수 있다는 기본적인 제한이 있습니다. 빌드 시스템이 구현된 언어나 규칙이 작성된 언어와 관계없이 (같지 않아도 됨) 규칙 작성자는 Skyframe을 우회하는 표준 라이브러리나 패턴을 사용하면 안 됩니다. Java의 경우 java.io.File과 모든 형태의 리플렉션, 둘 중 하나를 실행하는 라이브러리를 피해야 합니다. 이러한 하위 수준 인터페이스의 종속 항목 삽입을 지원하는 라이브러리는 Skyframe에 맞게 올바르게 설정되어야 합니다.
이는 규칙 작성자에게 처음부터 전체 언어 런타임을 노출하지 않는 것이 좋다는 것을 강력하게 시사합니다. 이러한 API를 실수로 사용할 위험이 너무 큽니다. 과거에 Bazel팀이나 다른 도메인 전문가가 규칙을 작성했음에도 불구하고 안전하지 않은 API를 사용하는 규칙으로 인해 여러 Bazel 버그가 발생했습니다.
2차 시간 및 메모리 소비를 피하기 어려움
설상가상으로 Skyframe에서 부과하는 요구사항, Java 사용의 역사적 제약, 규칙 API의 오래됨 외에도 2차 시간 또는 메모리 소비를 실수로 도입하는 것은 라이브러리 및 바이너리 규칙에 기반한 모든 빌드 시스템의 근본적인 문제입니다. 2차 메모리 소비 (따라서 2차 시간 소비)를 유발하는 매우 일반적인 패턴이 두 가지 있습니다.
라이브러리 규칙 체인 - 라이브러리 규칙 체인 A가 B에 종속되고 C에 종속되는 등의 경우를 고려합니다. 그런 다음 각 라이브러리의 Java 런타임 클래스 경로 또는 C++ 링커 명령과 같은 이러한 규칙의 전이적 폐쇄에 대한 속성을 계산하려고 합니다. 단순히 표준 목록 구현을 사용할 수도 있지만, 이렇게 하면 이미 2차 메모리 소비가 발생합니다. 첫 번째 라이브러리에는 클래스 경로에 항목이 하나 있고, 두 번째 라이브러리에는 두 개, 세 번째 라이브러리에는 세 개가 있는 등 총 1+2+3+...+N = O(N^2) 항목이 있습니다.
동일한 라이브러리 규칙에 종속된 바이너리 규칙 - 동일한 라이브러리 코드를 테스트하는 여러 테스트 규칙이 있는 경우와 같이 동일한 라이브러리 규칙에 종속된 바이너리 집합의 경우를 고려합니다. N개의 규칙 중 절반은 바이너리 규칙이고 나머지 절반은 라이브러리 규칙이라고 가정해 보겠습니다. 이제 각 바이너리가 Java 런타임 클래스 경로 또는 C++ 링커 명령줄과 같은 라이브러리 규칙의 전이적 폐쇄에 대해 계산된 일부 속성의 사본을 만든다고 가정해 보겠습니다. 예를 들어 C++ 링크 작업의 명령줄 문자열 표현식을 확장할 수 있습니다. N/2 요소의 N/2 복사본은 O(N^2) 메모리입니다.
이차 복잡성을 방지하는 맞춤 컬렉션 클래스
Bazel은 이러한 두 시나리오의 영향을 많이 받으므로 각 단계에서 복사를 방지하여 메모리의 정보를 효과적으로 압축하는 맞춤 컬렉션 클래스 집합을 도입했습니다. 이러한 데이터 구조는 거의 모두 집합 시맨틱스를 설정하므로 depset(내부 구현에서는 NestedSet
이라고도 함)이라고 합니다. 지난 몇 년간 Bazel의 메모리 사용량을 줄이기 위한 대부분의 변경사항은 이전에 사용된 항목 대신 depsets를 사용하도록 변경하는 것이었습니다.
안타깝게도 depsets 사용이 모든 문제를 자동으로 해결하지는 않습니다. 특히 각 규칙에서 depset을 반복하는 것만으로도 2차 시간 소비가 다시 도입됩니다. 내부적으로 NestedSets에는 일반 컬렉션 클래스와의 상호 운용성을 용이하게 하는 몇 가지 도우미 메서드도 있습니다. 하지만 실수로 NestedSet을 이러한 메서드 중 하나에 전달하면 복사 동작이 발생하고 이차 메모리 소비가 다시 도입됩니다.