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

Báo cáo sự cố Xem nguồn

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ể trong việc viết các quy tắc Bazel hiệu quả.

Yêu cầu đối với bản tóm tắt

  • Giả định: Mục tiêu về độ chính xác, công suất, tính 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 như XÂY DỰNG
  • Lịch sử: Việc phân tách cứng 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
  • Nội tại: Việc thực thi từ xa và lưu vào bộ nhớ đệm rất khó
  • Nội tại: Sử dụng thông tin thay đổi cho các bản dựng chính xác và tăng dần nhanh chóng
  • Nội tại: Khó tránh được thời gian bậc hai và mức tiêu thụ bộ nhớ

Các giả định

Sau đây là một số giả định 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 giải quyết những giả định này và đưa ra các nguyên tắc để đảm bảo các quy tắc được viết một cách hiệu quả.

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

Chúng tôi giả định rằng hệ thống xây dựng cần phải là hệ thống chính xác trước tiên và tuân theo 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 đi đến 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ề mức độ chính xác mà Bazel có thể nhận được, vì phương pháp này làm rò rỉ một số thông tin như ngày / giờ của bản dựng và bỏ qua một số loại thay đổi nhất định, chẳng hạn như các thay đổi đối với thuộc tính tệp. Chế độ 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 cho 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ó một vài vấn đề về độ chính xác đã biết, hầu hết đều liên quan đến Tập hợp tệp hoặc các quy tắc C++, cả hai đều là những vấn đề khó. Chúng tôi có những nỗ lực lâu dài để khắc phục những vấn đề này.

Mục tiêu thứ hai của hệ thống xây dựng là đạt thông lượng cao; chúng tôi đang mở rộng vĩnh viễn những giới hạn có thể thực hiện được trong hoạt động phân bổ máy hiện tại cho một 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.

Tính dễ sử dụng là điều tiếp theo. Trong số nhiều phương pháp chính xác có cùng một mức sử dụng (hoặc tương tự) của 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 từ khi khởi động bản dựng cho đế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, hoặc thông báo lỗi cho biết tệp BUILD có lỗi chính tả.

Xin lưu ý rằng các mục tiêu này thường trùng lặp; độ trễ là một chức năng của công suất 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ữ có quy mô lớn

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

Ngôn ngữ mô tả giống như XÂY DỰNG

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

Cổ

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

Việc phân tách cứng 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 thao tác đó được gửi đến thực thi từ xa. Tuy nhiên, cơ sở mã Bazel ban đầu có việc phân tách nghiêm ngặt các gói tải, sau đó phân tích quy tắc bằng cách sử dụng cấu hình (về cơ bản là cờ hiệu dòng lệnh) và sau đó chỉ chạy bất kỳ hành động nào. Sự khác biệt này hiện vẫn là một phần của API quy tắc, mặc dù cốt lõi của Bazel không còn đòi hỏi điều đó nữa (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 (API có các thuộc tính gì, các loại thuộc tính). Có một số 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 ẩn 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" sẽ ngầm tạo ra một đầu ra có tên "libfoo.jar". Kết quả 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, quá trình phân tích quy tắc không thể đọc bất kỳ tệp nguồn nào hay kiểm tra kết quả của một hành động; thay vào đó, quá trình phân tích cần phải tạo biểu đồ hai bên có hướng một phần gồm các bước xây dựng 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 các quy tắc viết trở nên khó khăn và một số thuộc tính phổ biến nhất sẽ được mô tả trong các phần sau.

Quá trình thực thi từ xa và lưu vào bộ nhớ đệm rất khó khăn

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 bậc độ lớn 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à nó cần thực hiện lại rất đáng kinh ngạc: dịch vụ thực thi từ xa của Google được thiết kế để xử lý một số lượng lớn yêu cầu mỗi giây và giao thức này cẩn thận tránh những lượt trả về không cần thiết cũng như các 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ả các dữ liệu đầu vào cho một hành động nhất định; sau đó, hệ thống xây dựng sẽ tính toán vân tay số của hành động duy nhất và yêu cầu trình lập lịch biểu cho một lần truy cập vào 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 sẽ được giải quyết bằng chuỗi đại diện sau đó. Tuy nhiên, việc này áp dụng các hạn chế đối với các quy tắc Bazel. Các quy tắc này cần khai báo trước tất cả tệp đầu vào.

Việc sử dụng thông tin thay đổi cho các bản dựng tăng dần 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 đi đến bước tạo bản dựng để phát hiện xem bước tạo bản dựng đó có còn mới nhất hay không. Điều này cũng đúng với việc tải gói và phân tích quy tắc. Nhìn chung, chúng tôi đã thiết kế Skyframe để xử lý việc này. Skyframe là một thư viện biểu đồ và khung đánh giá sử dụng 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 sẽ đọc các gói, phân tích quy tắc và thực thi các hành động.

Tại mỗi nút, Skyframe sẽ theo dõi chính xác nút bất kỳ được dùng để tính toán đầu ra riêng, từ nút mục tiêu đến tệp đầu vào (cũng là các nút Skyframe). Việc biểu diễn biểu đồ này rõ ràng trong bộ nhớ cho phép hệ thống xây dựng xác định chính xác nút nào bị ảnh hưởng bởi 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.

Trong quá trình này, mỗi nút thực hiện một quá 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. Theo nguyên tắc, cách này sẽ ánh xạ tốt đến mô hình luồng trên mỗi nút. Tuy nhiên, các bản dựng kích thước trung bình chứa hàng trăm nghìn nút Skyframe. Đây là điều không thể dễ dàng với công nghệ Java hiện tại (và vì lý do trước đây, chúng ta hiện liên quan đến việc sử dụng Java, nên không có luồng nhẹ cũng như không có các nút 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 một phần phụ thuộc chưa có sẵn, chúng ta có thể phải huỷ việc đánh giá đó rồi khởi động lại (có thể trong một luồng khác), khi phần phụ thuộc này có sẵn. Điều này có nghĩa là các nút không nên làm việc này quá mức; một nút khai báo N phần phụ thuộc tuần tự có thể được khởi động lại N lần, tốn thời gian O(N^2) . 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, việc này yêu cầu 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 không có trong API quy tắc. Thay vào đó, API quy tắc vẫn được xác định bằng cách sử dụng các khái niệm cũ về các giai đoạn tải, phân tích và thực thi. Tuy nhiên, có 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 để 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 hoặc quy tắc được viết bằng ngôn ngữ nào (chúng không nhất thiết phải giống nhau), tác giả quy tắc không được sử dụng 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ư bất kỳ hình thức phản chiếu nào và bất kỳ thư viện nào tương tự. Các thư viện hỗ trợ tính năng chèn phần phụ thuộc của các giao diện cấp thấp này vẫn cần được thiết lập chính xác cho Skyframe.

Điều này thực sự khuyên bạn nên tránh để tác giả quy tắc được hiển thị đầy đủ thời gian chạy ngôn ngữ ngay từ đầu. Nguy cơ vô tình sử dụng những API như vậy là quá lớn. Một số 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 do nhóm Bazel hoặc các chuyên gia khác về miền viết ra.

Việc tránh thời gian và mức sử dụng bộ nhớ bậc hai thật khó khăn

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

  1. Chuỗi quy tắc thư viện – Hãy xem xét trường hợp 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 ta sẽ tính toán một số thuộc tính qua việc đóng bắc cầu của các quy tắc này, chẳng hạn như classpath thời gian chạy Java hoặc lệnh trình liên kết C++ cho từng thư viện. Thường thì chúng ta có thể triển khai danh sách chuẩn; tuy nhiên, cách này đã triển khai mức tiêu thụ bộ nhớ bậc hai: thư viện đầu tiên chứa một mục trên classpath, thư viện thứ hai, ba mục thứ ba và tương tự như vậy cho tổng cộng 1+2+3+...+N = O(N^2).

  2. Quy tắc nhị phân phụ thuộc vào các quy tắc giống nhau của thư viện – Hãy xem xét trường hợp một nhóm 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 số N quy tắc, một nửa còn lại là các quy tắc nhị phân và một nửa còn lại là các quy tắc của thư viện. Bây giờ, hãy xem xét 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 trạng thái đóng bắc cầu của các quy tắc thư viện, chẳng hạn như classpath thời gian chạy Java hoặc dòng lệnh trình liên kết C++. Ví dụ: tính năng này có thể mở rộng bản trình bày chuỗi dòng lệnh của thao tác liên kết C++. Bản sao N/2 của các phần tử N/2 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 nặng nề 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ớ một cách hiệu quả bằng cách tránh sao chép ở mỗi bước. Hầu như tất cả 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 nó là phần phụ thuộc (còn 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 phần phụ thuộc thay vì bất kỳ điều gì từng sử dụng trước đây.

Rất tiếc, việc sử dụng các phần phụ thuộc không tự động giải quyết tất cả vấn đề; cụ thể là việc chỉ lặp lại qua một phần phụ thuộc trong mỗi quy tắc sẽ giới thiệu lại mức tiêu thụ thời gian bậc hai. 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 tập hợp thông thường. Thật không may, việc vô tình truyền một NestedSet vào một trong những phương thức này dẫn đến việc sao chép hành vi và giới thiệu lại mức tiêu thụ bộ nhớ bậc hai.