Tối ưu hóa phân bổ chuỗi dự phòng trong C ++


10

Tôi có một thành phần C ++ khá phức tạp mà hiệu suất của nó đã trở thành một vấn đề. Hồ sơ cho thấy rằng hầu hết thời gian thực hiện chỉ đơn giản là dành phân bổ bộ nhớ cho std::strings.

Tôi biết rằng có rất nhiều sự dư thừa trong số các chuỗi đó. Một số ít các giá trị lặp lại rất thường xuyên nhưng cũng có rất nhiều giá trị duy nhất. Các chuỗi thường khá ngắn.

Bây giờ tôi chỉ nghĩ nếu nó có ý nghĩa để sử dụng lại những phân bổ thường xuyên đó. Thay vì 1000 con trỏ đến 1000 giá trị "foobar" riêng biệt, tôi có thể có 1000 con trỏ tới một giá trị "foobar". Thực tế là điều này sẽ hiệu quả hơn về bộ nhớ là một phần thưởng tuyệt vời nhưng tôi chủ yếu quan tâm đến độ trễ ở đây.

Tôi đoán một tùy chọn sẽ là duy trì một loại sổ đăng ký các giá trị đã được phân bổ nhưng thậm chí có thể làm cho việc tra cứu sổ đăng ký nhanh hơn phân bổ bộ nhớ dự phòng không? Đây có phải là một cách tiếp cận khả thi?


6
Khả thi? Có chắc chắn - các ngôn ngữ khác thực hiện việc này thường xuyên (ví dụ: Java - tìm kiếm chuỗi thực tập). Tuy nhiên, một điều quan trọng cần xem xét là các đối tượng được lưu trong bộ nhớ cache cần phải bất biến, điều mà std :: string không có.
Hulk

2
Câu hỏi này có liên quan hơn: stackoverflow.com/q/26130941
rwong

8
Bạn đã phân tích các loại thao tác chuỗi chi phối ứng dụng của bạn? Có phải là sao chép, trích xuất chuỗi con, nối, thao tác theo từng ký tự không? Mỗi loại hoạt động đòi hỏi các kỹ thuật tối ưu hóa khác nhau. Ngoài ra, vui lòng kiểm tra xem trình biên dịch và triển khai thư viện chuẩn của bạn có hỗ trợ "tối ưu hóa chuỗi nhỏ" hay không. Cuối cùng, nếu bạn sử dụng thực tập chuỗi, hiệu suất của hàm băm cũng rất quan trọng.
rwong

2
Bạn đang làm gì với những chuỗi đó? Có phải chúng chỉ được sử dụng như một số loại định danh hoặc khóa? Hoặc chúng được kết hợp để tạo ra một số đầu ra? Nếu vậy, làm thế nào để bạn thực hiện nối chuỗi? Với +toán tử hay với các chuỗi chuỗi? Các chuỗi đến từ đâu? Chữ trong mã của bạn hoặc đầu vào bên ngoài?
amon

Câu trả lời:


3

Tôi dựa rất nhiều vào các chuỗi được thực hiện như Basile gợi ý, trong đó một tra cứu chuỗi chuyển thành chỉ mục 32 bit để lưu trữ và so sánh. Điều đó hữu ích trong trường hợp của tôi vì đôi khi tôi có hàng trăm nghìn đến hàng triệu thành phần có thuộc tính có tên là "x", ví dụ, vẫn cần phải là một tên chuỗi thân thiện với người dùng vì nó thường được các nhà viết kịch bản truy cập theo tên.

Tôi sử dụng bộ ba để tra cứu (cũng đã thử nghiệm unordered_mapnhưng bộ ba được điều chỉnh của tôi được hỗ trợ bởi nhóm bộ nhớ ít nhất bắt đầu hoạt động tốt hơn và cũng dễ dàng hơn để tạo luồng an toàn mà không bị khóa mỗi khi cấu trúc được truy cập) nhưng không phải như vậy nhanh chóng để xây dựng như tạo ra std::string. Vấn đề quan trọng hơn là tăng tốc các hoạt động tiếp theo như kiểm tra sự bằng nhau của chuỗi, trong trường hợp của tôi, chỉ cần kiểm tra hai số nguyên để tìm sự bằng nhau và giảm đáng kể việc sử dụng bộ nhớ.

Tôi đoán một tùy chọn sẽ là duy trì một loại sổ đăng ký các giá trị đã được phân bổ nhưng thậm chí có thể làm cho việc tra cứu sổ đăng ký nhanh hơn phân bổ bộ nhớ dự phòng không?

Điều đó sẽ rất khó để thực hiện tìm kiếm thông qua cấu trúc dữ liệu nhanh hơn nhiều so với một malloc, ví dụ: Nếu bạn gặp trường hợp bạn đang đọc một chuỗi các chuỗi từ đầu vào bên ngoài như tệp, chẳng hạn, thì cám dỗ của tôi sẽ là sử dụng bộ cấp phát tuần tự nếu có thể. Điều đó đi kèm với nhược điểm là bạn không thể giải phóng bộ nhớ của một chuỗi riêng lẻ. Tất cả bộ nhớ được phân bổ bởi bộ cấp phát phải được giải phóng cùng một lúc hoặc không. Nhưng một bộ cấp phát tuần tự có thể có ích trong trường hợp bạn chỉ cần phân bổ một khối lượng bộ nhớ nhỏ có kích thước thay đổi theo kiểu tuần tự thẳng, chỉ sau đó mới ném nó đi. Tôi không biết điều đó có áp dụng trong trường hợp của bạn hay không, nhưng khi áp dụng, có thể là một cách dễ dàng để khắc phục một điểm nóng liên quan đến việc phân bổ bộ nhớ tuổi teen thường xuyên (có thể liên quan đến lỗi bộ nhớ cache và lỗi trang hơn so với bên dưới thuật toán được sử dụng bởi, nói, malloc).

Phân bổ có kích thước cố định sẽ dễ dàng tăng tốc hơn mà không bị ràng buộc phân bổ tuần tự ngăn bạn giải phóng các khối bộ nhớ cụ thể để được sử dụng lại sau này. Nhưng thực hiện phân bổ kích thước thay đổi nhanh hơn phân bổ mặc định là khá khó khăn. Về cơ bản làm cho bất kỳ loại cấp phát bộ nhớ nào nhanh hơn mallocthường rất khó khăn nếu bạn không áp dụng các ràng buộc làm thu hẹp khả năng ứng dụng của nó. Một giải pháp là sử dụng bộ cấp phát có kích thước cố định cho tất cả các chuỗi có 8 byte trở xuống nếu bạn có tải trọng của chúng và các chuỗi dài hơn là một trường hợp hiếm gặp (mà bạn chỉ có thể sử dụng bộ cấp phát mặc định). Điều đó không có nghĩa là 7 byte bị lãng phí cho các chuỗi 1 byte, nhưng nó sẽ loại bỏ các điểm nóng liên quan đến phân bổ, nếu, giả sử, 95% thời gian, chuỗi của bạn rất ngắn.

Một giải pháp khác xảy ra với tôi là sử dụng các danh sách được liên kết không được kiểm soát, nghe có vẻ điên rồ nhưng hãy nghe tôi nói.

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

Ý tưởng ở đây là làm cho mỗi nút không được kiểm soát có kích thước cố định thay vì kích thước thay đổi. Khi bạn làm điều đó, bạn có thể sử dụng bộ cấp phát khối có kích thước cố định cực nhanh, chứa bộ nhớ, phân bổ các khối có kích thước cố định cho các chuỗi có kích thước thay đổi được liên kết với nhau. Điều đó sẽ không làm giảm việc sử dụng bộ nhớ, nó sẽ có xu hướng thêm vào vì chi phí của các liên kết, nhưng bạn có thể chơi với kích thước không được kiểm soát để tìm sự cân bằng phù hợp với nhu cầu của mình. Đó là một ý tưởng kỳ quặc nhưng nên loại bỏ các điểm nóng liên quan đến bộ nhớ vì giờ đây bạn có thể tập hợp hiệu quả bộ nhớ đã được phân bổ trong các khối liền kề cồng kềnh và vẫn có lợi ích của việc giải phóng các chuỗi riêng lẻ. Đây là một công cụ phân bổ cố định đơn giản mà tôi đã viết (một minh họa tôi đã làm cho người khác, không có lông tơ liên quan đến sản xuất) mà bạn có thể tự do sử dụng:

#ifndef FIXED_ALLOCATOR_HPP
#define FIXED_ALLOCATOR_HPP

class FixedAllocator
{
public:
    /// Creates a fixed allocator with the specified type and block size.
    explicit FixedAllocator(int type_size, int block_size = 2048);

    /// Destroys the allocator.
    ~FixedAllocator();

    /// @return A pointer to a newly allocated chunk.
    void* allocate();

    /// Frees the specified chunk.
    void deallocate(void* mem);

private:
    struct Block;
    struct FreeElement;

    FreeElement* free_element;
    Block* head;
    int type_size;
    int num_block_elements;
};

#endif

#include "FixedAllocator.hpp"
#include <cstdlib>

struct FixedAllocator::FreeElement
{
    FreeElement* next_element;
};

struct FixedAllocator::Block
{
    Block* next;
    char* mem;
};

FixedAllocator::FixedAllocator(int type_size, int block_size): free_element(0), head(0)
{
    type_size = type_size > sizeof(FreeElement) ? type_size: sizeof(FreeElement);
    num_block_elements = block_size / type_size;
    if (num_block_elements == 0)
        num_block_elements = 1;
}

FixedAllocator::~FixedAllocator()
{
    // Free each block in the list, popping a block until the stack is empty.
    while (head)
    {
        Block* block = head;
        head = head->next;
        free(block->mem);
        free(block);
    }
    free_element = 0;
}

void* FixedAllocator::allocate()
{
    // Common case: just pop free element and return.
    if (free_element)
    {
        void* mem = free_element;
        free_element = free_element->next_element;
        return mem;
    }

    // Rare case when we're out of free elements.
    // Create new block.
    Block* new_block = static_cast<Block*>(malloc(sizeof(Block)));
    new_block->mem = malloc(type_size * num_block_elements);
    new_block->next = head;
    head = new_block;

    // Push all but one of the new block's elements to the free stack.
    char* mem = new_block->mem;
    for (int j=1; j < num_block_elements; ++j)
    {
        void* ptr = mem + j*type_size;
        FreeElement* element = static_cast<FreeElement*>(ptr);
        element->next_element = free_element;
        free_element = element;
    }
    return mem;
}

void FixedAllocator::deallocate(void* mem)
{
    // Just push a free element to the stack.
    FreeElement* element = static_cast<FreeElement*>(mem);
    element->next_element = free_element;
    free_element = element;
}


0

Ngày xửa ngày xưa khi xây dựng trình biên dịch, chúng tôi đã sử dụng một thứ gọi là chủ tịch dữ liệu (thay vì ngân hàng dữ liệu, một bản dịch tiếng Đức thông dụng cho DB). Điều này chỉ đơn giản là tạo ra một hàm băm cho một chuỗi và sử dụng nó để phân bổ. Vì vậy, bất kỳ chuỗi nào không phải là một phần bộ nhớ trên heap / stack mà là mã băm vào ghế dữ liệu này. Bạn có thể thay thế Stringbởi một lớp như vậy. Cần khá nhiều mã làm lại, mặc dù. Và tất nhiên điều này chỉ có thể sử dụng cho các chuỗi r / o.


Điều gì về copy-on-write. Nếu bạn thay đổi chuỗi, bạn sẽ tính toán lại hàm băm và khôi phục nó. Hoặc điều đó sẽ không làm việc?
Jerry Jeremiah

@JerryJeremiah Điều đó phụ thuộc vào ứng dụng của bạn. Bạn có thể thay đổi chuỗi được biểu thị bằng hàm băm và khi bạn truy xuất đại diện băm, bạn sẽ nhận được giá trị mới. Trong ngữ cảnh trình biên dịch, bạn sẽ tạo một hàm băm mới cho một chuỗi mới.
qwerty_so

0

Lưu ý cách phân bổ bộ nhớ và bộ nhớ thực tế được sử dụng đều liên quan đến hiệu suất kém:

Tất nhiên, chi phí phân bổ bộ nhớ là rất cao. Do đó, chuỗi std :: có thể đã sử dụng phân bổ tại chỗ cho các chuỗi nhỏ và do đó lượng phân bổ thực tế có thể thấp hơn mức bạn nghĩ trước tiên. Trong trường hợp kích thước của bộ đệm này không đủ lớn, thì bạn có thể được truyền cảm hứng bởi ví dụ: lớp chuỗi của Facebook ( https://github.com/facebook/realy/blob/master/ Scratchly / BFString.h ) sử dụng 23 ký tự nội bộ trước khi phân bổ.

Chi phí sử dụng nhiều bộ nhớ cũng đáng chú ý. Đây có lẽ là vi phạm lớn nhất: Bạn có thể có nhiều RAM trong máy, tuy nhiên, kích thước bộ đệm vẫn đủ nhỏ để nó ảnh hưởng đến hiệu suất khi truy cập bộ nhớ chưa được lưu vào bộ nhớ cache. Bạn có thể đọc về điều này tại đây: https://en.wikipedia.org/wiki/Locality_of numference


0

Thay vì làm cho các hoạt động chuỗi nhanh hơn, một cách tiếp cận khác là giảm số lượng các hoạt động chuỗi. Chẳng hạn, có thể thay thế các chuỗi bằng một enum không?

Một cách tiếp cận khác có thể hữu ích được sử dụng trong Ca cao: Có những trường hợp bạn có hàng trăm hoặc hàng ngàn từ điển, tất cả đều có cùng một khóa. Vì vậy, họ cho phép bạn tạo một đối tượng là một tập hợp các khóa từ điển và có một hàm tạo từ điển lấy một đối tượng đó làm đối số. Từ điển hoạt động giống như bất kỳ từ điển nào khác, nhưng khi bạn thêm một cặp khóa / giá trị với một khóa trong bộ khóa đó, khóa không bị trùng lặp mà chỉ là một con trỏ tới khóa trong bộ khóa được lưu trữ. Vì vậy, hàng ngàn từ điển này chỉ cần một bản sao của mỗi chuỗi khóa trong bộ đó.

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.