Phân bổ bộ nhớ động và quản lý bộ nhớ


17

Trong một trò chơi trung bình, có hàng trăm hoặc có thể hàng ngàn đối tượng trong cảnh. Là hoàn toàn chính xác để phân bổ bộ nhớ cho tất cả các đối tượng, bao gồm cả tiếng súng (đạn), tự động thông qua mặc định mới () ?

Tôi có nên tạo bất kỳ nhóm bộ nhớ nào để phân bổ động hay không cần phải bận tâm đến vấn đề này? Nếu nền tảng đích là thiết bị di động thì sao?

Có cần một người quản lý bộ nhớ trong một trò chơi di động không? Cảm ơn bạn.

Ngôn ngữ được sử dụng: C ++; Hiện đang được phát triển dưới Windows, nhưng dự định sẽ được chuyển sau.


Ngôn ngữ nào?
Kylotan

@Kylotan: ngôn ngữ được sử dụng: C ++ hiện được phát triển trong Windows nhưng dự định sẽ được chuyển sau.
Bunkai.Satori

Câu trả lời:


23

Trong một trò chơi trung bình, có hàng trăm hoặc có thể hàng ngàn vật cản trong cảnh. Là hoàn toàn chính xác để phân bổ bộ nhớ cho tất cả các đối tượng, bao gồm cả tiếng súng (đạn), tự động thông qua mặc định mới ()?

Điều đó thực sự phụ thuộc vào những gì bạn có nghĩa là "chính xác." Nếu bạn sử dụng thuật ngữ hoàn toàn theo nghĩa đen (và bỏ qua bất kỳ khái niệm về tính chính xác của thiết kế ngụ ý) thì có, nó hoàn toàn chấp nhận được. Chương trình của bạn sẽ biên dịch và chạy tốt.

Nó có thể thực hiện tối ưu phụ, nhưng nó vẫn có thể hoạt động đủ tốt để trở thành một trò chơi vui nhộn, có thể thay đổi.

Tôi có nên tạo bất kỳ nhóm bộ nhớ nào để phân bổ động hay không cần phải bận tâm đến vấn đề này? Nếu nền tảng đích là thiết bị di động thì sao?

Hồ sơ và xem. Ví dụ, trong C ++, phân bổ động trên heap thường là thao tác "chậm" (trong đó liên quan đến việc đi qua heap tìm kiếm một khối có kích thước phù hợp). Trong C #, nó thường là một hoạt động cực kỳ nhanh chóng vì nó liên quan đến ít hơn một sự gia tăng. Các triển khai ngôn ngữ khác nhau có các đặc tính hiệu suất khác nhau liên quan đến phân bổ bộ nhớ, phân mảnh khi phát hành, et cetera.

Việc triển khai một hệ thống tổng hợp bộ nhớ chắc chắn có thể mang lại hiệu suất tăng - và vì các hệ thống di động thường không đủ sức mạnh so với các hệ thống máy tính để bàn, bạn có thể thấy nhiều lợi ích hơn trên một nền tảng di động cụ thể so với trên máy tính để bàn. Nhưng một lần nữa, bạn phải lập hồ sơ và xem - nếu, hiện tại, trò chơi của bạn chậm nhưng phân bổ / giải phóng bộ nhớ không hiển thị trên hồ sơ như một điểm nóng, triển khai cơ sở hạ tầng để tối ưu hóa phân bổ bộ nhớ và truy cập có thể giành chiến thắng ' t giúp bạn nhiều bang cho buck của bạn.

Có cần một người quản lý bộ nhớ trong một trò chơi di động không? Cảm ơn bạn.

Một lần nữa, hồ sơ và xem. Trò chơi của bạn có chạy tốt không? Sau đó, bạn có thể không cần phải lo lắng.

Nói một cách thận trọng, sử dụng phân bổ động cho mọi thứ không cần thiết phải nói một cách nghiêm túc và vì vậy có thể thuận lợi để tránh điều đó - cả vì tăng hiệu suất tiềm năng và vì phân bổ bộ nhớ mà bạn cần theo dõi và cuối cùng giải phóng có nghĩa là bạn phải theo dõi và cuối cùng phát hành nó, có thể làm phức tạp mã của bạn.

Cụ thể, trong ví dụ ban đầu của bạn, bạn đã trích dẫn "đạn", có xu hướng là thứ được tạo ra và phá hủy thường xuyên - bởi vì nhiều trò chơi liên quan đến rất nhiều viên đạn, và đạn di chuyển nhanh và do đó nhanh chóng đến cuối đời (và thường dữ dội!). Vì vậy, việc triển khai bộ cấp phát cho nhóm và các đối tượng như chúng (chẳng hạn như các hạt trong hệ thống hạt) thường có thể mang lại hiệu quả và có thể là nơi đầu tiên bắt đầu xem xét sử dụng phân bổ nhóm.

Tôi không rõ nếu bạn đang xem việc triển khai nhóm bộ nhớ khác với "trình quản lý bộ nhớ" - nhóm bộ nhớ là một khái niệm được xác định tương đối rõ ràng, vì vậy tôi có thể chắc chắn rằng chúng có thể là một lợi ích nếu bạn triển khai chúng . Một "trình quản lý bộ nhớ" mơ hồ hơn một chút về trách nhiệm của nó, vì vậy tôi sẽ phải nói rằng việc có yêu cầu hay không phụ thuộc vào những gì bạn nghĩ rằng "trình quản lý bộ nhớ" sẽ làm gì.

Ví dụ: nếu bạn coi trình quản lý bộ nhớ là một thứ chỉ chặn các cuộc gọi đến mới / xóa / miễn phí / malloc / bất cứ điều gì và cung cấp chẩn đoán về số lượng bộ nhớ bạn phân bổ, những gì bạn rò rỉ, et cetera - thì đó có thể hữu ích công cụ cho trò chơi trong khi nó đang được phát triển để giúp bạn gỡ lỗi rò rỉ và điều chỉnh kích thước nhóm bộ nhớ tối ưu của bạn, v.v.


Đã đồng ý. Mã theo cách cho phép bạn thay đổi mọi thứ sau này. Nếu nghi ngờ, điểm chuẩn hoặc hồ sơ.
axel22

@ Josh: +1 cho câu trả lời xuất sắc. Những gì tôi có thể cần phải có là sự kết hợp của phân bổ động, phân bổ tĩnh và nhóm bộ nhớ. Tuy nhiên, hiệu suất của trò chơi sẽ hướng dẫn tôi kết hợp đúng ba thứ đó. Đây là ứng cử viên rõ ràng cho câu trả lời được chấp nhận cho câu hỏi của tôi. Tuy nhiên, tôi muốn giữ câu hỏi mở trong một thời gian, để xem những gì người khác sẽ đóng góp.
Bunkai.Satori

+1. Công phu tuyệt vời. Câu trả lời cho hầu hết mọi câu hỏi về hiệu suất luôn là "hồ sơ và xem". Phần cứng quá phức tạp ngày nay để lý giải về hiệu suất từ ​​các nguyên tắc đầu tiên. Bạn cần dữ liệu.
hào phóng

@Munificent: cảm ơn bình luận của bạn. Vì vậy, mục tiêu là làm cho trò chơi hoạt động và ổn định. Không cần phải lo lắng quá nhiều về hiệu suất ở giữa sự phát triển. Tất cả có thể và sẽ được sửa chữa sau khi hoàn thành trò chơi.
Bunkai.Satori

Tôi nghĩ rằng đây là sự thể hiện không công bằng về thời gian phân bổ của C # - ví dụ: mọi phân bổ C # cũng bao gồm một khối đồng bộ hóa, phân bổ Đối tượng, v.v. Ngoài ra, heap trong C ++ chỉ yêu cầu sửa đổi khi phân bổ và giải phóng, trong khi C # yêu cầu các bộ sưu tập .
DeadMG

7

Tôi không có nhiều điều để thêm vào câu trả lời xuất sắc của Josh, nhưng tôi sẽ bình luận về điều này:

Tôi có nên tạo bất kỳ nhóm bộ nhớ nào để phân bổ động hay không cần phải bận tâm đến vấn đề này?

Có một nền tảng trung gian giữa các nhóm bộ nhớ và gọi newtrên mỗi phân bổ. Ví dụ: bạn có thể phân bổ số lượng đối tượng đã đặt trong một mảng, sau đó đặt cờ trên chúng để 'tiêu diệt' chúng sau này. Khi bạn cần phân bổ nhiều hơn, bạn có thể ghi đè lên những cái có bộ cờ bị phá hủy. Loại điều này chỉ phức tạp hơn một chút để sử dụng so với mới / xóa (vì bạn sẽ có 2 chức năng mới cho mục đích đó) nhưng đơn giản để viết và có thể mang lại cho bạn lợi nhuận lớn.


+1 để bổ sung tốt đẹp. Vâng, bạn đã đúng, đó là một cách tốt để quản lý các yếu tố trò chơi đơn giản hơn như: đạn, hạt, hiệu ứng. Đặc biệt đối với những người đó, sẽ không cần phải phân bổ bộ nhớ một cách linh hoạt.
Bunkai.Satori

3

Là hoàn toàn chính xác để phân bổ bộ nhớ cho tất cả các đối tượng, bao gồm cả súng (đạn), tự động thông qua mặc định mới ()?

Tất nhiên là không rồi. Không phân bổ bộ nhớ là chính xác cho tất cả các đối tượng. Toán tử new () dành cho phân bổ động , nghĩa là chỉ phù hợp nếu bạn cần phân bổ động, vì thời gian tồn tại của đối tượng là động hoặc do loại đối tượng là động. Nếu loại và thời gian tồn tại của đối tượng được biết tĩnh, bạn nên phân bổ tĩnh.

Tất nhiên, bạn càng có nhiều thông tin về các mẫu phân bổ của mình, các phân bổ này có thể được thực hiện nhanh hơn thông qua các phân bổ chuyên gia, chẳng hạn như nhóm đối tượng. Nhưng, đây là những tối ưu hóa và bạn chỉ nên thực hiện chúng nếu chúng được biết là cần thiết.


+1 cho câu trả lời tốt. Vì vậy, để khái quát hóa, cách tiếp cận chính xác sẽ là: khi bắt đầu phát triển, lập kế hoạch, những đối tượng nào có thể được phân bổ tĩnh. Trong quá trình phát triển, để phân bổ động chỉ những đối tượng hoàn toàn phải được phân bổ động. Cuối cùng, để cấu hình và điều chỉnh các vấn đề hiệu suất phân bổ bộ nhớ có thể.
Bunkai.Satori

0

Kiểu gợi ý của Kylotan lặp lại nhưng tôi khuyên bạn nên giải quyết vấn đề này ở cấp cấu trúc dữ liệu khi có thể, chứ không phải ở cấp phân bổ thấp hơn nếu bạn có thể giúp đỡ.

Đây là một ví dụ đơn giản về cách bạn có thể tránh phân bổ và giải phóng Foosnhiều lần bằng cách sử dụng một mảng có lỗ với các phần tử được liên kết với nhau (giải quyết vấn đề này ở cấp độ "container" thay vì mức "cấp phát"):

struct FooNode
{
    explicit FooNode(const Foo& ielement): element(ielement), next(-1) {}

    // Stores a 'Foo'.
    Foo element;

    // Points to the next foo available; either the
    // next used foo or the next deleted foo. Can
    // use SoA and hoist this out if Foo doesn't 
    // have 32-bit alignment.
    int next;
};

struct Foos
{
    // Stores all the Foo nodes.
    vector<FooNode> nodes;

    // Points to the first used node.
    int first_node;

    // Points to the first free node.
    int free_node;

    Foos(): first_node(-1), free_node(-1)
    {
    }

    const FooNode& operator[](int n) const
    {
         return data[n];
    }

    void insert(const Foo& element)
    {
         int index = free_node;
         if (index != -1)
         {
              // If there's a free node available,
              // pop it from the free list, overwrite it,
              // and push it to the used list.
              free_node = data[index].next;
              data[index].next = first_node;
              data[index].element = element;
              first_node = index;
         }
         else
         {
              // If there's no free node available, add a 
              // new node and push it to the used list.
              FooNode new_node(element);
              new_node.next = first_node;
              first_node = data.size() - 1;
              data.push_back(new_node);
         }
    }

    void erase(int n)
    {
         // If the node being removed is the first used
         // node, pop it from the used list.
         if (first_node == n)
              first_node = data[n].next;

         // Push the node to the free list.
         data[n].next = free_node;
         free_node = n;
    }
};

Một cái gì đó cho hiệu ứng này: một danh sách chỉ mục liên kết đơn với một danh sách miễn phí. Các liên kết chỉ mục cho phép bạn bỏ qua các phần tử bị loại bỏ, loại bỏ các phần tử trong thời gian không đổi và cũng có thể lấy lại / tái sử dụng / ghi đè các phần tử miễn phí bằng cách chèn thời gian không đổi. Để lặp qua cấu trúc, bạn làm một cái gì đó như thế này:

for (int index = foos.first_node; index != -1; index = foos[index].next)
    // do something with foos[index]

nhập mô tả hình ảnh ở đây

Và bạn có thể khái quát cấu trúc dữ liệu "mảng lỗ được liên kết" ở trên bằng cách sử dụng các mẫu, gọi vị trí dtor mới và thủ công để tránh yêu cầu gán sao chép, làm cho nó gọi hàm hủy khi các phần tử được loại bỏ, cung cấp một trình lặp chuyển tiếp, v.v. đã chọn giữ ví dụ rất giống C để minh họa rõ hơn cho khái niệm này và cũng bởi vì tôi rất lười biếng.

Điều đó nói rằng, cấu trúc này không có xu hướng xuống cấp tại địa phương sau khi bạn loại bỏ và chèn nhiều thứ vào / từ giữa. Tại thời điểm đó, các nextliên kết có thể khiến bạn đi đi lại lại dọc theo vectơ, tải lại dữ liệu trước đây bị xóa khỏi một dòng bộ đệm trong cùng một giao dịch tuần tự (điều này là không thể tránh khỏi với bất kỳ cấu trúc dữ liệu hoặc bộ cấp phát nào cho phép loại bỏ thời gian liên tục mà không bị xáo trộn khoảng trắng từ giữa với chèn thời gian liên tục và không sử dụng cái gì đó như bitet song song hoặc removedcờ). Để khôi phục tính thân thiện với bộ đệm, bạn có thể thực hiện phương thức sao chép và trao đổi như thế này:

Foos(const Foos& other)
{
    for (int index = other.first_node; index != -1; index = other[index].next)
        insert(foos[index].element);
}

void Foos::swap(Foos& other)
{
     nodes.swap(other.nodes):
     std::swap(first_node, other.first_node);
     std::swap(free_node, other.free_node);
}

// ... then just copy and swap:
Foos(foos).swap(foos);

Bây giờ phiên bản mới lại thân thiện với bộ đệm. Một phương pháp khác là lưu trữ một danh sách các chỉ mục riêng biệt vào cấu trúc và sắp xếp chúng theo định kỳ. Một cách khác là sử dụng một bitet để chỉ ra những chỉ số nào được sử dụng. Điều đó sẽ luôn có bạn đi qua các bit theo thứ tự tuần tự (để thực hiện điều này một cách hiệu quả, hãy kiểm tra 64 bit tại một thời điểm, ví dụ như sử dụng FFS / FFZ). Bitet là hiệu quả nhất và không xâm phạm, chỉ cần một bit song song cho mỗi phần tử để chỉ ra phần tử nào được sử dụng và phần nào bị xóa thay vì yêu cầu chỉ mục 32 bit next, nhưng tốn nhiều thời gian nhất để viết tốt (nó sẽ không nhanh chóng truyền tải nếu bạn đang kiểm tra từng bit một - bạn cần FFS / FFZ để tìm một tập hợp hoặc bỏ đặt bit ngay lập tức trong số hơn 32 bit tại một thời điểm để nhanh chóng xác định phạm vi các chỉ số bị chiếm dụng).

Giải pháp được liên kết này nói chung là dễ thực hiện nhất và không xâm phạm (không yêu cầu sửa đổi Foođể lưu trữ một số removedcờ) rất hữu ích nếu bạn muốn tổng quát hóa vùng chứa này để hoạt động với bất kỳ loại dữ liệu nào nếu bạn không nhớ rằng 32 bit phí trên mỗi phần tử.

Tôi có nên tạo bất kỳ nhóm bộ nhớ nào để phân bổ động hay không cần phải bận tâm đến vấn đề này? Nếu nền tảng đích là thiết bị di động thì sao?

cần là một từ mạnh mẽ và tôi thiên vị khi làm việc trong các lĩnh vực rất quan trọng về hiệu suất như raytracing, xử lý hình ảnh, mô phỏng hạt và xử lý lưới, nhưng việc phân bổ và giải phóng các vật thể tuổi teen được sử dụng để xử lý rất nhẹ như đạn là tương đối tốn kém và các hạt riêng lẻ dựa trên bộ cấp phát bộ nhớ có kích thước chung, đa mục đích. Cho rằng bạn sẽ có thể khái quát cấu trúc dữ liệu trên trong một hoặc hai ngày để lưu trữ bất cứ thứ gì bạn muốn, tôi nghĩ rằng đó là một trao đổi đáng giá để loại bỏ chi phí phân bổ / phân bổ heap như vậy hoàn toàn khỏi việc trả cho mỗi điều thiếu niên. Ngoài việc giảm chi phí phân bổ / phân bổ, bạn có được địa phương tốt hơn của việc tham chiếu khi đi qua kết quả (tức là ít lỗi bộ nhớ cache và lỗi trang hơn).

Đối với những gì Josh đã đề cập về GC, tôi đã không nghiên cứu triển khai GC của C # gần giống như Java, nhưng các bộ cấp phát GC thường có phân bổ ban đầuđiều đó rất nhanh bởi vì đó là sử dụng bộ cấp phát tuần tự không thể giải phóng bộ nhớ từ giữa (gần giống như một ngăn xếp, bạn không thể xóa những thứ ở giữa). Sau đó, nó trả cho các chi phí đắt đỏ để thực sự cho phép loại bỏ các đối tượng riêng lẻ trong một luồng riêng biệt bằng cách sao chép bộ nhớ và xóa toàn bộ bộ nhớ được phân bổ trước đó (như phá hủy toàn bộ ngăn xếp cùng một lúc trong khi sao chép dữ liệu vào một thứ giống như cấu trúc được liên kết), nhưng vì nó được thực hiện trong một luồng riêng biệt, nên nó không nhất thiết làm trì hoãn các luồng của ứng dụng của bạn rất nhiều. Tuy nhiên, điều đó mang một chi phí ẩn rất đáng kể về một mức độ gián tiếp bổ sung và mất LOR chung sau một chu kỳ GC ban đầu. Đó là một chiến lược khác để tăng tốc độ phân bổ mặc dù - làm cho nó rẻ hơn trong chuỗi cuộc gọi và sau đó thực hiện công việc đắt tiền ở nơi khác. Vì vậy, bạn cần hai cấp độ gián tiếp để tham chiếu các đối tượng của mình thay vì một cấp vì cuối cùng chúng sẽ bị xáo trộn trong bộ nhớ giữa thời gian bạn phân bổ ban đầu và sau một chu kỳ đầu tiên.

Một chiến lược khác theo cách tương tự dễ áp ​​dụng hơn trong C ++ là đừng bận tâm giải phóng các đối tượng của bạn trong các luồng chính. Chỉ cần tiếp tục thêm và thêm và thêm vào cuối cấu trúc dữ liệu không cho phép xóa những thứ ở giữa. Tuy nhiên, đánh dấu những thứ cần phải loại bỏ. Sau đó, một luồng riêng biệt có thể đảm nhiệm công việc tốn kém trong việc tạo cấu trúc dữ liệu mới mà không cần các phần tử bị loại bỏ và sau đó trao đổi nguyên tử cái mới với cái cũ, ví dụ: Phần lớn chi phí của cả hai phần tử phân bổ và giải phóng có thể được chuyển cho tách luồng nếu bạn có thể đưa ra giả định rằng yêu cầu xóa phần tử không phải được thỏa mãn ngay lập tức. Điều đó không chỉ làm cho việc giải phóng rẻ hơn khi có liên quan đến chủ đề của bạn mà còn giúp phân bổ rẻ hơn, vì bạn có thể sử dụng cấu trúc dữ liệu đơn giản và gọn gàng hơn nhiều mà không bao giờ phải xử lý các trường hợp loại bỏ từ giữa. Nó giống như một container chỉ cần mộtpush_backchức năng chèn, một clearchức năng để loại bỏ tất cả các yếu tố và swaptrao đổi nội dung với một thùng chứa mới, nhỏ gọn, ngoại trừ các yếu tố bị loại bỏ; đó là xa như biến đổi đi.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.