규칙 작성의 과제

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.
문제 신고 소스 보기

이 페이지에서는 효율적인 Bazel 규칙을 작성할 때 구체적으로 어떤 문제와 문제가 있는지 개략적으로 설명합니다.

요약 요구사항

  • 가정: 정확성, 처리량, 사용 편의성 및 지연 시간 목표
  • 가정: 대규모 저장소
  • 가정: BUILD와 비슷한 설명 언어
  • 이력: 로드, 분석, 실행 간의 엄격한 분리는 오래되었지만 API에는 여전히 영향을 미칩니다.
  • 내장 기능: 원격 실행과 캐싱이 어려움
  • 내장 기능: 정확하고 빠른 증분 빌드에 변경 정보 사용 시 특수한 코딩 패턴이 필요함
  • 본질적: 이차 시간과 메모리 소비를 피하기는 어려움

가정

다음은 정확성, 사용 용이성, 처리량, 대규모 저장소 등에 관한 빌드 시스템에 관한 몇 가지 가정입니다. 다음 섹션에서는 이러한 가정을 해결하고 규칙을 효과적인 방식으로 작성할 수 있도록 가이드라인을 제공합니다.

정확성, 처리량, 사용 편의성, 지연 시간을 목표로 함

빌드 시스템은 점진적 빌드를 고려하여 무엇보다도 정확해야 한다고 가정합니다. 소스 트리의 경우 출력 트리 모양과 관계없이 항상 동일한 빌드의 출력이 동일해야 합니다. 첫 번째 근사값에서 Bazel은 지정된 빌드 단계에 들어가는 모든 단일 입력에 대해 파악해야 하므로 입력 중 하나라도 변경되면 해당 단계를 다시 실행할 수 있습니다. Bazel이 빌드 날짜 / 시간과 같은 일부 정보를 유출하고 파일 속성 변경사항과 같은 특정 유형의 변경사항은 무시하므로, 올바른 방법으로 가져오는 데 제한이 있습니다. 샌드박스를 사용하면 선언되지 않은 입력 파일에 대한 읽기를 방지하여 정확성을 유지할 수 있습니다. 시스템의 본질적인 제한사항 외에도 몇 가지 알려진 정확성 문제가 있으며, 그중 대부분이 파일 세트 또는 C++ 규칙과 관련이 있는데 둘 다 어려운 문제입니다. 이 문제를 해결하기 위해 장기적인 노력을 기울이고 있습니다.

빌드 시스템의 두 번째 목표는 처리량을 높이는 것입니다. 원격 실행 서비스의 현재 머신 할당 내에서 할 수 있는 작업의 경계를 영구적으로 밀어내고 있습니다. 원격 실행 서비스에 과부하가 발생하면 아무도 작업을 실행할 수 없습니다.

다음은 사용 편의성입니다. 원격 실행 서비스와 동일한 (또는 유사한) 사용 공간을 가진 여러 개의 올바른 접근 방식 중에서 더 쉬운 접근 방식을 선택합니다.

지연 시간은 테스트를 통과하거나 실패한 테스트의 테스트 로그인지 또는 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은 목표 라이브러리 (예: 'build //foo with these options')를 구성요소 구성요소로 분할한 후 평가 및 결합한 결과를 얻는 그래프 라이브러리 및 평가 프레임워크입니다. 이 프로세스의 일부로 Skyframe은 패키지를 읽고 규칙을 분석하며 작업을 실행합니다.

Skyframe은 각 노드에서 자체 노드가 노드를 출력하는 데 사용된 노드를 정확히 추적하며, 목표 노드에서 입력 파일 (Skyframe 노드이기도 함)에 이르기까지 다양합니다. 그래프에서 메모리에 명시적으로 표현하면 빌드 시스템이 입력 파일의 변경 (입력 파일 생성 또는 삭제 포함)에 의해 영향을 받는 노드를 정확히 식별할 수 있으므로 출력 트리를 의도된 상태로 복원하기 위한 최소한의 작업을 실행합니다.

이 과정에서 각 노드는 종속 항목 검색 프로세스를 수행합니다. 각 노드는 종속 항목을 선언하고 이러한 종속 항목의 콘텐츠를 사용하여 추가 종속 항목을 선언할 수 있습니다. 원칙적으로 노드당 스레드 모델에 잘 매핑됩니다. 그러나 중간 크기의 빌드에는 수십만 개의 스카이프레임 노드가 포함되어 있습니다. 이는 현재 자바 기술로는 불가능하지만 (현재는 자바를 사용하고 있기 때문에 경량 스레드나 연속이 없습니다.)

대신 Bazel은 고정 크기 스레드 풀을 사용합니다. 그러나 노드가 아직 사용할 수 없는 종속 항목을 선언하면 종속 항목을 사용할 수 있을 때 평가를 취소하고 다른 스레드에서 다시 시작해야 할 수도 있습니다. 즉, 노드는 이를 과도하게 수행해서는 안 됩니다. N 종속 항목을 순차적으로 선언하는 노드는 잠재적으로 다시 시작될 수 있으며 이 경우 O(N^2) 시간이 소요됩니다. 대신 종속 항목의 사전 일괄 선언을 목표로 합니다. 즉, 코드를 재구성하거나 다시 시작 횟수를 제한하기 위해 노드를 여러 노드로 분할해야 하는 경우도 있습니다.

이 규칙은 현재 규칙 API에서 사용할 수 없습니다. 대신 규칙 API는 로드, 분석, 실행 단계의 기존 개념을 사용하여 계속 정의됩니다. 그러나 기본적인 제한사항은 다른 노드에 대한 모든 액세스가 상응하는 종속 항목을 추적할 수 있도록 프레임워크를 거쳐야 한다는 것입니다. 빌드 시스템이 구현되는 언어 또는 규칙이 기록되는 언어 (동일한 언어일 필요는 없음)와 관계없이 규칙 작성자는 스카이프레임을 우회하는 표준 라이브러리 또는 패턴을 사용해서는 안 됩니다. 자바의 경우 java.io.File과 모든 형태의 리플렉션 및 이러한 형식을 갖는 라이브러리를 사용하지 않아야 합니다. 이러한 하위 수준 인터페이스의 종속 항목 삽입을 지원하는 라이브러리는 여전히 스카이프레임에 맞게 올바르게 설정되어야 합니다.

따라서 규칙 작성자를 전체 언어 런타임에 노출하지 않는 것이 좋습니다. 실수로 해당 API를 사용할 때의 위험은 너무 큽니다. 이전에 Bazel 팀이나 다른 도메인 전문가가 작성한 규칙이라도 안전하지 않은 API를 사용하는 규칙으로 인해 여러 Bazel 버그가 발생했습니다.

2차 시간과 메모리 소비를 피하는 것이 어려움

설상가상으로 Skyframe에서 시행하는 요구사항, 자바 사용의 이전 제약 조건, 규칙 API의 오래된 사용을 제외하면 라이브러리 및 바이너리 규칙을 기반으로 하는 모든 빌드 시스템에서 2차 시간 또는 메모리 소비를 실수로 도입할 수 있습니다. 2차 메모리 소비와 관련된 2가지 일반적인 패턴 (따라서 2차 시간 소비)이 있습니다.

  1. 라이브러리 규칙 체인 - 라이브러리 규칙 A가 B, C 등에 종속되는 체인의 경우를 생각해 보세요. 그런 다음 이러한 규칙의 전이적 클로저(예: 자바 런타임 클래스 경로 또는 각 라이브러리의 C++ 링커 명령어)를 통해 몇 가지 속성을 계산하려고 합니다. 간단히 말해, 표준 목록 구현을 사용할 수 있습니다. 그러나 이는 이미 2차 메모리 소비를 가져옵니다. 첫 번째 라이브러리에는 클래스 경로에 한 항목이 포함되고, 두 번째 라이브러리에는 세 번째 항목, 세 번째 항목 등이 포함되며 총 1+2+3+...+N = O(N^2) 항목이 포함됩니다.

  2. 동일한 라이브러리 규칙에 따른 바이너리 규칙 - 예를 들어 동일한 라이브러리 코드를 테스트하는 여러 테스트 규칙이 있는 경우와 같이 동일한 라이브러리 규칙에 종속되는 바이너리 집합이 있는 경우를 생각해 보세요. N개 규칙 중 절반은 바이너리 규칙, 나머지 절반은 라이브러리 규칙이라고 가정해 보겠습니다. 이제 각 바이너리가 자바 런타임 클래스 경로 또는 C++ 링커 명령줄과 같은 라이브러리 규칙의 전이적 클로저를 통해 계산된 일부 속성의 사본을 만드는 경우를 생각해 봅시다. 예를 들어 C++ 링크 작업의 명령줄 문자열 표현을 확장할 수 있습니다. N/2 요소의 N/2 사본은 O(N^2) 메모리입니다.

2차 복잡도를 피하기 위한 커스텀 컬렉션 클래스

Bazel은 이러한 두 시나리오에 큰 영향을 받으므로 각 단계에서 복사를 방지하여 메모리의 정보를 효과적으로 압축하는 커스텀 컬렉션 클래스 집합을 도입했습니다. 거의 모든 이러한 데이터 구조에는 시맨틱이 설정되어 있기 때문에 디셋(내부 구현에서는 NestedSet이라고도 함)이라고 부릅니다. 지난 몇 년 동안 Bazel의 메모리 소비를 줄이기 위한 대부분의 변경사항은 이전에 사용된 것이 아닌 디셋을 사용하도록 변경하는 것이었습니다.

디펜스를 사용한다고 해서 모든 문제가 자동으로 해결되는 것은 아닙니다. 특히 각 규칙에서 디프셋을 반복하는 것만으로는 2차 시간 소비가 발생합니다. 내부적으로 NestedSet에는 일반 컬렉션 클래스와의 상호 운용성을 지원하는 도우미 메서드도 있습니다. 안타깝게도 실수로 이러한 메서드 중 하나에 NestedSet을 전달하면 동작이 복사되고 이차 메모리 소비가 다시 발생합니다.