Thách thức của việc viết quy tắc

Báo cáo vấn đề Xem nguồn Nightly/3}

Trang này cung cấp thông tin tổng quan ở cấp cao về các vấn đề và thách thức cụ thể khi viết các quy tắc Bazel hiệu quả.

Yêu cầu về phần tóm tắt

  • Giả định: Nhắm đến độ chính xác, công suất, mức độ dễ sử dụng và độ trễ
  • Giả định: Kho lưu trữ quy mô lớn
  • Giả định: Ngôn ngữ mô tả giống với BUILD
  • Quá khứ: Sự khác biệt khó đoán giữa quá trình Tải, Phân tích và Thực thi đã lỗi thời, nhưng vẫn ảnh hưởng đến API
  • Nội tại: Khó thực thi từ xa và lưu vào bộ nhớ đệm
  • Nội tại: Việc sử dụng thông tin thay đổi cho các bản dựng chính xác và gia tăng nhanh chóng đòi hỏi các mẫu lập trình bất thường
  • Nội tại: Tránh được thời gian bậc hai và mức sử dụng bộ nhớ là rất khó

Các giả định

Dưới đây là một số giả định được đưa ra về hệ thống xây dựng, chẳng hạn như nhu cầu về độ chính xác, tính dễ sử dụng, công suất và kho lưu trữ quy mô lớn. Các phần sau đây đề cập đến những giả định này và đưa ra nguyên tắc để đảm bảo viết các quy tắc một cách hiệu quả.

Hướng đến độ chính xác, công suất, tính dễ sử dụng và độ trễ

Chúng tôi giả định rằng trước tiên, hệ thống xây dựng cần phải chính xác nhất đối với các bản dựng tăng dần. Đối với một cây nguồn nhất định, đầu ra của cùng một bản dựng phải luôn giống nhau, bất kể cây đầu ra trông như thế nào. Trong phép ước tính đầu tiên, điều này có nghĩa là Bazel cần biết mọi dữ liệu đầu vào riêng lẻ trải qua một bước xây dựng nhất định để có thể chạy lại bước đó nếu có bất kỳ dữ liệu đầu vào nào thay đổi. Có những giới hạn về số lượng chính xác mà Bazel có thể nhận được, vì công cụ này sẽ rò rỉ một số thông tin như ngày / giờ tạo bản dựng và bỏ qua một số loại thay đổi nhất định, chẳng hạn như thay đổi thuộc tính tệp. Hộp cát giúp đảm bảo tính chính xác bằng cách ngăn các lượt đọc tệp đầu vào chưa khai báo. Bên cạnh các giới hạn nội tại của hệ thống, còn có một vài vấn đề về độ chính xác đã biết, hầu hết đều liên quan đến Fileset hoặc quy tắc C++. Đây đều là những vấn đề khó khăn. Chúng tôi đang nỗ lực lâu dài để khắc phục vấn đề này.

Mục tiêu thứ hai của hệ thống xây dựng là đạt công suất cao; chúng tôi sẽ đẩy vĩnh viễn những ranh giới của những việc có thể làm trong quy trình phân bổ máy hiện tại cho dịch vụ thực thi từ xa. Nếu dịch vụ thực thi từ xa bị quá tải, thì không ai có thể hoàn thành công việc.

Tiếp theo là tính dễ sử dụng. Trong số nhiều phương pháp chính xác có cùng mức sử dụng (hoặc tương tự) dịch vụ thực thi từ xa, chúng tôi chọn phương pháp dễ sử dụng hơn.

Độ trễ biểu thị thời gian cần thiết từ khi bắt đầu tạo bản dựng đến khi nhận được kết quả dự kiến, cho dù đó là nhật ký kiểm thử của một lượt kiểm thử đạt/không đạt, hay một thông báo lỗi cho biết tệp BUILD có lỗi đánh máy.

Xin lưu ý rằng các mục tiêu này thường trùng lặp nhau; độ trễ cũng là một hàm số của công suất của dịch vụ thực thi từ xa cũng như độ chính xác phù hợp để dễ sử dụng.

Kho lưu trữ quy mô lớn

Hệ thống xây dựng cần hoạt động trên quy mô của những kho lưu trữ lớn (với quy mô lớn) không thể vừa với một ổ đĩa cứng. Vì vậy, hệ thống không thể thanh toán toàn bộ trên hầu hết máy của nhà phát triển. Một bản dựng có kích thước trung bình sẽ cần đọc và phân tích cú pháp hàng chục nghìn tệp BUILD, cũng như đánh giá hàng trăm nghìn hình cầu. Mặc dù về mặt lý thuyết, bạn có thể đọc tất cả các tệp BUILD trên một máy, nhưng chúng tôi chưa thể thực hiện việc này trong một khoảng thời gian và bộ nhớ hợp lý. Do đó, điều quan trọng là các tệp BUILD phải có thể được tải và phân tích cú pháp một cách độc lập.

Ngôn ngữ mô tả giống với BUILD

Trong ngữ cảnh này, chúng tôi giả định ngôn ngữ cấu hình gần giống với tệp BUILD trong phần khai báo thư viện và quy tắc nhị phân, cũng như các phần phụ thuộc lẫn nhau của chúng. Các tệp BUILD có thể được đọc và phân tích cú pháp một cách độc lập, đồng thời chúng ta tránh xem xét các tệp nguồn bất cứ khi nào có thể (trừ trường hợp tồn tại).

Cổ

Có sự khác biệt giữa các phiên bản Bazel gây ra thử thách và một số phiên bản trong số này được nêu trong những phần sau.

Quá trình phân tách hoàn toàn giữa tải, phân tích và thực thi đã lỗi thời nhưng vẫn ảnh hưởng đến API

Về mặt kỹ thuật, một quy tắc chỉ cần biết các tệp đầu vào và đầu ra của một thao tác ngay trước khi hành động đó được gửi đi để thực thi từ xa. Tuy nhiên, cơ sở mã Bazel ban đầu có sự phân tách nghiêm ngặt các gói tải, sau đó phân tích các quy tắc bằng cách sử dụng cấu hình (về cơ bản là cờ dòng lệnh) và sau đó chỉ chạy bất kỳ hành động nào. Sự khác biệt này vẫn là một phần trong các quy tắc API hiện nay, mặc dù điểm cốt lõi của Bazel không còn yêu cầu nó (xem thêm thông tin chi tiết ở bên dưới).

Điều đó có nghĩa là API quy tắc yêu cầu nội dung mô tả khai báo về giao diện quy tắc (các thuộc tính có trong API, loại thuộc tính). Có một số trường hợp ngoại lệ mà API cho phép mã tuỳ chỉnh chạy trong giai đoạn tải để tính toán tên ngầm của các tệp đầu ra và giá trị ngầm ẩn của các thuộc tính. Ví dụ: quy tắc java_library có tên "foo" ngầm tạo ra một dữ liệu đầu ra có tên là "libfoo.jar", dữ liệu này có thể được tham chiếu từ các quy tắc khác trong biểu đồ bản dựng.

Hơn nữa, việc phân tích một quy tắc không thể đọc bất kỳ tệp nguồn nào hoặc kiểm tra kết quả của một hành động; thay vào đó, việc phân tích một quy tắc cần tạo một đồ thị hai phần theo hướng một phần về các bước tạo và tên tệp đầu ra chỉ được xác định từ chính quy tắc đó và các phần phụ thuộc của quy tắc đó.

Nội tại

Có một số thuộc tính nội tại khiến quy tắc viết trở nên khó khăn và một số thuộc tính phổ biến nhất được mô tả trong các phần sau.

Khó thực thi từ xa và lưu vào bộ nhớ đệm

Quá trình thực thi từ xa và lưu vào bộ nhớ đệm giúp cải thiện thời gian xây dựng trong các kho lưu trữ lớn khoảng 2 thứ tự về cường độ so với việc chạy bản dựng trên một máy duy nhất. Tuy nhiên, quy mô mà lệnh này cần thực hiện thật đáng kinh ngạc: Dịch vụ thực thi từ xa của Google được thiết kế để xử lý số lượng lớn các yêu cầu mỗi giây và giao thức này cẩn thận tránh các lượt trả về không cần thiết cũng như công việc không cần thiết ở phía dịch vụ.

Tại thời điểm này, giao thức yêu cầu hệ thống xây dựng biết trước tất cả dữ liệu đầu vào cho một hành động nhất định; sau đó, hệ thống xây dựng phải tính toán một vân tay số hành động duy nhất và yêu cầu trình lập lịch biểu cung cấp lượt truy cập bộ nhớ đệm. Nếu tìm thấy lượt truy cập vào bộ nhớ đệm, trình lập lịch biểu sẽ phản hồi bằng chuỗi đại diện của các tệp đầu ra; các tệp này sẽ được giải quyết bằng chuỗi đại diện sau đó. Tuy nhiên, điều này áp dụng các hạn chế đối với các quy tắc Bazel, vốn cần khai báo trước tất cả các tệp đầu vào.

Việc sử dụng thông tin thay đổi để có các bản dựng gia tăng chính xác và nhanh chóng đòi hỏi các mẫu lập trình bất thường

Ở trên, chúng tôi đã lập luận rằng để chính xác, Bazel cần biết tất cả các tệp đầu vào nằm trong bước tạo bản dựng để phát hiện xem bước tạo bản dựng đó có còn cập nhật hay không. Điều này cũng đúng đối với việc tải gói và phân tích quy tắc, và chúng tôi đã thiết kế Skyframe để xử lý vấn đề này nói chung. Skyframe là một thư viện biểu đồ và khung đánh giá có một nút mục tiêu (chẳng hạn như "tạo //foo với các tuỳ chọn này") và chia nhỏ thành các phần cấu thành, sau đó được đánh giá và kết hợp để tạo ra kết quả này. Trong quá trình này, Skyframe đọc các gói, phân tích quy tắc và thực thi các thao tác.

Tại mỗi nút, Skyframe theo dõi chính xác các nút mà bất kỳ nút nhất định nào được dùng để tính toán đầu ra riêng, từ nút mục tiêu cho đến các tệp đầu vào (cũng là các nút Skyframe). Việc biểu thị rõ ràng biểu đồ này trong bộ nhớ cho phép hệ thống xây dựng xác định chính xác những nút nào chịu ảnh hưởng của một thay đổi nhất định đối với tệp đầu vào (bao gồm cả việc tạo hoặc xoá tệp đầu vào), thực hiện lượng công việc tối thiểu để khôi phục cây đầu ra về trạng thái dự kiến.

Mỗi nút thực hiện một quy trình khám phá phần phụ thuộc. Mỗi nút có thể khai báo các phần phụ thuộc, sau đó sử dụng nội dung của các phần phụ thuộc đó để khai báo thêm các phần phụ thuộc khác. Về nguyên tắc, thuộc tính này liên kết tốt với mô hình luồng trên mỗi nút. Tuy nhiên, các bản dựng quy mô trung bình chứa hàng trăm nghìn nút Skyframe, điều này không dễ dàng thực hiện được với công nghệ Java hiện tại (và vì lý do trước đây, chúng tôi hiện liên quan đến việc sử dụng Java nên không có luồng nhẹ và không có phần tiếp tục).

Thay vào đó, Bazel sử dụng một nhóm luồng có kích thước cố định. Tuy nhiên, điều đó có nghĩa là nếu một nút khai báo phần phụ thuộc chưa có sẵn, thì chúng ta có thể phải huỷ đánh giá đó và khởi động lại (có thể trong một luồng khác), khi phần phụ thuộc có sẵn. Đổi lại, điều này có nghĩa là các nút không được làm điều này quá mức; một nút khai báo phần phụ thuộc N tuần tự có thể được khởi động lại N lần, tốn O(N^2) thời gian. Thay vào đó, chúng tôi hướng đến việc khai báo trước hàng loạt các phần phụ thuộc, đôi khi yêu cầu phải sắp xếp lại mã hoặc thậm chí chia một nút thành nhiều nút để hạn chế số lần khởi động lại.

Xin lưu ý rằng công nghệ này hiện chưa có trong API quy tắc; thay vào đó, API quy tắc vẫn được xác định bằng các khái niệm cũ về giai đoạn tải, phân tích và thực thi. Tuy nhiên, một hạn chế cơ bản là tất cả quyền truy cập vào các nút khác đều phải đi qua khung này để có thể theo dõi các phần phụ thuộc tương ứng. Bất kể ngôn ngữ mà hệ thống xây dựng được triển khai là gì hoặc quy tắc được viết bằng ngôn ngữ nào (các quy tắc đó không nhất thiết phải giống nhau), tác giả quy tắc không được sử dụng các thư viện hoặc mẫu chuẩn bỏ qua Skyframe. Đối với Java, điều đó có nghĩa là tránh java.io.File cũng như mọi hình thức phản chiếu và mọi thư viện có hỗ trợ tính năng này. Bạn vẫn cần thiết lập chính xác các thư viện hỗ trợ chèn phần phụ thuộc của các giao diện cấp thấp này cho Skyframe.

Điều này đặc biệt là bạn nên tránh hiển thị tác giả quy tắc cho môi trường thời gian chạy ngôn ngữ đầy đủ ngay từ đầu. Nguy cơ vô tình sử dụng những API như vậy là quá lớn – một vài lỗi Bazel trước đây là do các quy tắc sử dụng API không an toàn, mặc dù các quy tắc này là do nhóm Bazel hoặc các chuyên gia khác về miền viết ra.

Tránh sử dụng thời gian bậc hai và khó sử dụng bộ nhớ

Thậm chí còn tệ hơn, ngoài các yêu cầu mà Skyframe đặt ra, những hạn chế trước đây khi sử dụng Java và mức độ lỗi thời của API quy tắc, việc vô tình đưa ra thời gian bậc hai hoặc mức tiêu thụ bộ nhớ là một vấn đề cơ bản trong mọi hệ thống xây dựng dựa trên thư viện và quy tắc nhị phân. Có 2 mẫu rất phổ biến giới thiệu mức tiêu thụ bộ nhớ bậc hai (và do đó tiêu thụ thời gian theo bậc bốn).

  1. Chuỗi quy tắc thư viện – Hãy xem xét trường hợp một chuỗi quy tắc thư viện A phụ thuộc vào B, phụ thuộc vào C, v.v. Sau đó, chúng tôi muốn tính toán một số thuộc tính dựa trên việc đóng bắc cầu của các quy tắc này, chẳng hạn như đường dẫn lớp thời gian chạy Java hoặc lệnh trình liên kết C++ cho từng thư viện. Nói chung, chúng tôi có thể triển khai danh sách chuẩn; tuy nhiên, mục này đã giới thiệu mức tiêu thụ bộ nhớ bậc hai: thư viện đầu tiên chứa một mục trên đường dẫn lớp, thư viện thứ hai, ba mục thứ ba, v.v. cho tổng cộng 1+2+3+3+...+N = O(N^2) mục nhập.

  2. Quy tắc nhị phân Tuỳ thuộc vào cùng một Quy tắc của thư viện – Hãy xem xét trường hợp một tập hợp các tệp nhị phân phụ thuộc vào cùng một quy tắc thư viện – chẳng hạn như khi bạn có một số quy tắc kiểm thử kiểm thử cùng một mã thư viện. Giả sử trong N quy tắc, một nửa quy tắc là các quy tắc nhị phân và một nửa quy tắc còn lại là quy tắc thư viện. Bây giờ, hãy cân nhắc việc mỗi tệp nhị phân tạo một bản sao của một số thuộc tính được tính toán dựa trên việc đóng bắc cầu của các quy tắc thư viện, chẳng hạn như đường dẫn lớp thời gian chạy Java hoặc dòng lệnh trình liên kết C++. Ví dụ: lớp này có thể mở rộng chuỗi dòng lệnh đại diện cho thao tác liên kết C++. N/2 bản sao của N/2 phần tử là bộ nhớ O(N^2).

Các lớp tập hợp tuỳ chỉnh để tránh độ phức tạp bậc hai

Bazel bị ảnh hưởng rất nhiều bởi cả hai trường hợp này, vì vậy, chúng tôi đã giới thiệu một tập hợp các lớp thu thập tuỳ chỉnh có thể nén thông tin trong bộ nhớ hiệu quả bằng cách tránh việc sao chép ở mỗi bước. Hầu hết các cấu trúc dữ liệu này đều đã đặt ngữ nghĩa, vì vậy chúng tôi gọi đó là depset (còn được gọi là NestedSet trong quá trình triển khai nội bộ). Phần lớn các thay đổi nhằm giảm mức tiêu thụ bộ nhớ của Bazel trong vài năm qua là các thay đổi về việc sử dụng các phần phụ thuộc thay vì bất kỳ phần tử nào đã được sử dụng trước đó.

Thật không may, việc sử dụng phần phụ thuộc không tự động giải quyết tất cả vấn đề; cụ thể là ngay cả việc chỉ lặp lại một phần tách trong mỗi quy tắc cũng làm sử dụng lại mức sử dụng thời gian bậc bốn. Trong nội bộ, NestedSets cũng có một số phương thức trợ giúp để hỗ trợ khả năng tương tác với các lớp bộ sưu tập thông thường; không may là vô tình truyền NestedSet đến một trong các phương thức này dẫn đến hành vi sao chép và giới thiệu lại mức tiêu thụ bộ nhớ bậc hai.