Bất kỳ tối ưu hóa nào cho truy cập ngẫu nhiên trên một mảng rất lớn khi giá trị trong 95% trường hợp là 0 hoặc 1?


133

Có khả năng tối ưu hóa nào để truy cập ngẫu nhiên trên một mảng rất lớn không (tôi hiện đang sử dụng uint8_tvà tôi đang hỏi về những gì tốt hơn)

uint8_t MyArray[10000000];

khi giá trị tại bất kỳ vị trí nào trong mảng là

  • 0 hoặc 1 cho 95% của tất cả các trường hợp,
  • 2 trong 4% trường hợp,
  • giữa 3255 trong 1% trường hợp khác?

Vì vậy, có gì tốt hơn một uint8_tmảng để sử dụng cho việc này? Cần nhanh nhất có thể lặp trên toàn bộ mảng theo thứ tự ngẫu nhiên và điều này rất nặng về băng thông RAM, do đó, khi có nhiều hơn một luồng xử lý cùng lúc cho các mảng khác nhau, hiện tại toàn bộ băng thông RAM được bão hòa nhanh chóng.

Tôi đang hỏi vì cảm thấy rất không hiệu quả khi có một mảng lớn như vậy (10 MB) khi thực sự biết rằng hầu hết tất cả các giá trị, ngoài 5%, sẽ là 0 hoặc 1. Vì vậy, khi 95% tất cả các giá trị trong mảng thực sự sẽ chỉ cần 1 bit thay vì 8 bit, điều này sẽ làm giảm mức sử dụng bộ nhớ gần như một mức độ lớn. Cảm giác như phải có một giải pháp hiệu quả hơn về bộ nhớ sẽ giúp giảm đáng kể băng thông RAM cần thiết cho việc này, và kết quả là cũng nhanh hơn đáng kể khi truy cập ngẫu nhiên.


36
Hai bit (0/1 / xem hashtable) và hashtable cho các giá trị lớn hơn 1?
dùng253751

6
@ user202729 Nó phụ thuộc vào cái gì? Tôi nghĩ rằng đây là một câu hỏi thú vị cho bất cứ ai phải làm điều gì đó tương tự như tôi, vì vậy tôi muốn xem thêm một giải pháp phổ quát cho vấn đề này, không phải là một câu trả lời siêu cụ thể cho mã của tôi. Nếu nó phụ thuộc vào một cái gì đó, sẽ tốt hơn nếu có một câu trả lời giải thích nó phụ thuộc vào điều gì để mọi người đọc nó có thể hiểu nếu có một giải pháp tốt hơn cho trường hợp của mình.
JohnAl

7
Về cơ bản, những gì bạn đang hỏi về được gọi là thưa thớt .
Mateen Ulhaq

5
Cần thêm thông tin ... Tại sao truy cập ngẫu nhiên và các giá trị khác không theo một mẫu?
Ext3h

4
@IwillnotexistIdonotexist Một bước tiền mã hóa sẽ ổn, nhưng thỉnh thoảng mảng vẫn phải được sửa đổi, vì vậy bước tiền mã hóa không nên quá đắt.
JohnAl

Câu trả lời:


155

Một khả năng đơn giản xuất hiện trong đầu là giữ một mảng nén 2 bit cho mỗi giá trị cho các trường hợp phổ biến và 4 byte cho mỗi giá trị (24 bit cho chỉ số phần tử gốc, 8 bit cho giá trị thực, vì vậy (idx << 8) | value)) đã sắp xếp mảng cho những người khác.

Khi bạn tìm kiếm một giá trị, trước tiên bạn thực hiện tra cứu trong mảng 2bpp (O (1)); nếu bạn tìm thấy 0, 1 hoặc 2 thì đó là giá trị bạn muốn; nếu bạn tìm thấy 3 thì có nghĩa là bạn phải tìm nó trong mảng thứ cấp. Tại đây, bạn sẽ thực hiện tìm kiếm nhị phân để tìm chỉ số sở thích của mình bị dịch chuyển trái 8 (O (log (n) với n nhỏ, vì đây phải là 1%) và trích xuất giá trị từ 4 - 4 byte điều.

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

Đối với một mảng như mảng bạn đề xuất, điều này sẽ mất 10000000/4 = 2500000 byte cho mảng đầu tiên, cộng với 10000000 * 1% * 4 B = 400000 byte cho mảng thứ hai; do đó 2900000 byte, tức là ít hơn một phần ba của mảng ban đầu và phần được sử dụng nhiều nhất được giữ cùng nhau trong bộ nhớ, điều này rất tốt cho bộ nhớ đệm (thậm chí có thể phù hợp với L3).

Nếu bạn cần địa chỉ nhiều hơn 24 bit, bạn sẽ phải điều chỉnh "bộ nhớ thứ cấp"; Một cách đơn giản để mở rộng nó là có một mảng con trỏ 256 phần tử để chuyển qua 8 bit trên cùng của chỉ mục và chuyển tiếp đến một mảng được sắp xếp chỉ mục 24 bit như trên.


Điểm chuẩn nhanh

#include <algorithm>
#include <vector>
#include <stdint.h>
#include <chrono>
#include <stdio.h>
#include <math.h>

using namespace std::chrono;

/// XorShift32 generator; extremely fast, 2^32-1 period, way better quality
/// than LCG but fail some test suites
struct XorShift32 {
    /// This stuff allows to use this class wherever a library function
    /// requires a UniformRandomBitGenerator (e.g. std::shuffle)
    typedef uint32_t result_type;
    static uint32_t min() { return 1; }
    static uint32_t max() { return uint32_t(-1); }

    /// PRNG state
    uint32_t y;

    /// Initializes with seed
    XorShift32(uint32_t seed = 0) : y(seed) {
        if(y == 0) y = 2463534242UL;
    }

    /// Returns a value in the range [1, 1<<32)
    uint32_t operator()() {
        y ^= (y<<13);
        y ^= (y>>17);
        y ^= (y<<15);
        return y;
    }

    /// Returns a value in the range [0, limit); this conforms to the RandomFunc
    /// requirements for std::random_shuffle
    uint32_t operator()(uint32_t limit) {
        return (*this)()%limit;
    }
};

struct mean_variance {
    double rmean = 0.;
    double rvariance = 0.;
    int count = 0;

    void operator()(double x) {
        ++count;
        double ormean = rmean;
        rmean     += (x-rmean)/count;
        rvariance += (x-ormean)*(x-rmean);
    }

    double mean()     const { return rmean; }
    double variance() const { return rvariance/(count-1); }
    double stddev()   const { return std::sqrt(variance()); }
};

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

volatile unsigned out;

int main() {
    XorShift32 xs;
    std::vector<uint8_t> vec;
    int size = 10000000;
    for(int i = 0; i<size; ++i) {
        uint32_t v = xs();
        if(v < 1825361101)      v = 0; // 42.5%
        else if(v < 4080218931) v = 1; // 95.0%
        else if(v < 4252017623) v = 2; // 99.0%
        else {
            while((v & 0xff) < 3) v = xs();
        }
        vec.push_back(v);
    }
    populate(vec.data(), vec.size());
    mean_variance lk_t, arr_t;
    for(int i = 0; i<50; ++i) {
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += lookup(xs() % size);
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "lookup: %10d µs\n", dur);
            lk_t(dur);
        }
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += vec[xs() % size];
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "array:  %10d µs\n", dur);
            arr_t(dur);
        }
    }

    fprintf(stderr, " lookup |   ±  |  array  |   ±  | speedup\n");
    printf("%7.0f | %4.0f | %7.0f | %4.0f | %0.2f\n",
            lk_t.mean(), lk_t.stddev(),
            arr_t.mean(), arr_t.stddev(),
            arr_t.mean()/lk_t.mean());
    return 0;
}

(mã và dữ liệu luôn được cập nhật trong Bitbucket của tôi)

Đoạn mã trên cư trú một mảng phần tử 10M với dữ liệu ngẫu nhiên được phân phối dưới dạng OP được chỉ định trong bài đăng của họ, khởi tạo cấu trúc dữ liệu của tôi và sau đó:

  • thực hiện tra cứu ngẫu nhiên các yếu tố 10 triệu với cấu trúc dữ liệu của tôi
  • thực hiện tương tự thông qua mảng ban đầu.

(lưu ý rằng trong trường hợp tra cứu tuần tự, mảng luôn thắng theo một số đo lớn, vì đó là tra cứu thân thiện với bộ nhớ cache nhất mà bạn có thể làm)

Hai khối cuối cùng được lặp lại 50 lần và tính thời gian; ở cuối, độ lệch trung bình và độ lệch chuẩn cho từng loại tra cứu được tính toán và in, cùng với việc tăng tốc (lookup_mean / Array_mean).

Tôi đã biên dịch mã ở trên với g ++ 5.4.0 ( -O3 -staticcộng với một số cảnh báo) trên Ubuntu 16.04 và chạy nó trên một số máy; hầu hết trong số họ đang chạy Ubuntu 16.04, một số Linux cũ hơn, một số Linux mới hơn. Tôi không nghĩ hệ điều hành nên có liên quan trong trường hợp này.

            CPU           |  cache   |  lookup s)   |     array s)  | speedup (x)
Xeon E5-1650 v3 @ 3.50GHz | 15360 KB |  60011 ±  3667 |   29313 ±  2137 | 0.49
Xeon E5-2697 v3 @ 2.60GHz | 35840 KB |  66571 ±  7477 |   33197 ±  3619 | 0.50
Celeron G1610T  @ 2.30GHz |  2048 KB | 172090 ±   629 |  162328 ±   326 | 0.94
Core i3-3220T   @ 2.80GHz |  3072 KB | 111025 ±  5507 |  114415 ±  2528 | 1.03
Core i5-7200U   @ 2.50GHz |  3072 KB |  92447 ±  1494 |   95249 ±  1134 | 1.03
Xeon X3430      @ 2.40GHz |  8192 KB | 111303 ±   936 |  127647 ±  1503 | 1.15
Core i7 920     @ 2.67GHz |  8192 KB | 123161 ± 35113 |  156068 ± 45355 | 1.27
Xeon X5650      @ 2.67GHz | 12288 KB | 106015 ±  5364 |  140335 ±  6739 | 1.32
Core i7 870     @ 2.93GHz |  8192 KB |  77986 ±   429 |  106040 ±  1043 | 1.36
Core i7-6700    @ 3.40GHz |  8192 KB |  47854 ±   573 |   66893 ±  1367 | 1.40
Core i3-4150    @ 3.50GHz |  3072 KB |  76162 ±   983 |  113265 ±   239 | 1.49
Xeon X5650      @ 2.67GHz | 12288 KB | 101384 ±   796 |  152720 ±  2440 | 1.51
Core i7-3770T   @ 2.50GHz |  8192 KB |  69551 ±  1961 |  128929 ±  2631 | 1.85

Kết quả là ... hỗn hợp!

  1. Nói chung, trên hầu hết các máy này đều có một số loại tăng tốc, hoặc ít nhất là chúng ngang tầm.
  2. Hai trường hợp mảng thực sự bỏ qua quá trình tra cứu "cấu trúc thông minh" nằm trên một máy có nhiều bộ đệm và không quá bận rộn: Xeon E5-1650 ở trên (bộ nhớ cache 15 MB) là máy dựng đêm, hiện tại khá nhàn rỗi; Xeon E5-2697 (bộ nhớ cache 35 MB) là một máy tính toán hiệu năng cao, trong một khoảnh khắc nhàn rỗi. Thật có ý nghĩa, mảng ban đầu hoàn toàn phù hợp với bộ nhớ cache khổng lồ của họ, vì vậy cấu trúc dữ liệu nhỏ gọn chỉ làm tăng thêm độ phức tạp.
  3. Ở phía đối diện của "phổ hiệu suất" - nhưng một lần nữa mảng nhanh hơn một chút, có Celeron khiêm tốn cung cấp năng lượng cho NAS của tôi; nó có rất ít bộ đệm mà cả mảng và "cấu trúc thông minh" đều không phù hợp với nó. Các máy khác có bộ nhớ cache đủ nhỏ thực hiện tương tự.
  4. Xeon X5650 phải được thận trọng - chúng là các máy ảo trên một máy chủ ảo ổ cắm kép khá bận rộn; cũng có thể là vậy, mặc dù trên danh nghĩa nó có một lượng bộ nhớ cache kha khá, trong suốt thời gian thử nghiệm, nó đã bị các máy ảo hoàn toàn không liên quan đánh lừa nhiều lần.

7
@ John John Bạn không cần một cấu trúc. A uint32_tsẽ ổn thôi. Xóa một phần tử khỏi bộ đệm thứ cấp rõ ràng sẽ để nó được sắp xếp. Chèn một phần tử có thể được thực hiện với std::lower_boundvà sau đó insert(thay vì nối thêm và sắp xếp lại toàn bộ). Các bản cập nhật làm cho mảng thứ cấp kích thước đầy đủ hấp dẫn hơn nhiều - tôi chắc chắn sẽ bắt đầu với điều đó.
Martin Bonner hỗ trợ Monica

6
@ John John Vì giá trị là (idx << 8) + valbạn không phải lo lắng về phần giá trị - chỉ cần sử dụng so sánh thẳng. Nó sẽ luôn so sánh ít hơn ((idx+1) << 8) + valvà ít hơn((idx-1) << 8) + val
Martin Bonner hỗ trợ Monica

3
@JohnAl: nếu điều đó có thể hữu ích, tôi đã thêm một populatechức năng nên cư trú main_arrsec_arrtheo định dạng lookupmong đợi. Tôi đã không thực sự thử nó, vì vậy đừng hy vọng nó thực sự hoạt động chính xác :-); dù sao đi nữa, nó sẽ cung cấp cho bạn ý tưởng chung.
Matteo Italia

6
Tôi đang cho +1 này chỉ để đo điểm chuẩn. Rất vui khi thấy một câu hỏi về hiệu quả và với kết quả cho nhiều loại bộ xử lý! Đẹp!
Jack Aidley

2
@ John John Bạn nên hồ sơ cho trường hợp sử dụng thực tế của bạn và không có gì khác. Tốc độ phòng trắng không thành vấn đề.
Jack Aidley

33

Một lựa chọn khác có thể là

  • kiểm tra xem kết quả là 0, 1 hay 2
  • nếu không làm một tra cứu thường xuyên

Nói cách khác, một cái gì đó như:

unsigned char lookup(int index) {
    int code = (bmap[index>>2]>>(2*(index&3)))&3;
    if (code != 3) return code;
    return full_array[index];
}

trong đó bmapsử dụng 2 bit cho mỗi phần tử với giá trị 3 có nghĩa là "khác".

Cấu trúc này là tầm thường để cập nhật, sử dụng thêm 25% bộ nhớ nhưng phần lớn chỉ được tra cứu trong 5% trường hợp. Tất nhiên, như thường lệ, nếu đó là một ý tưởng tốt hay không phụ thuộc vào rất nhiều điều kiện khác, vì vậy câu trả lời duy nhất là thử nghiệm với việc sử dụng thực tế.


4
Tôi muốn nói rằng đó là một sự thỏa hiệp tốt để có được càng nhiều lần truy cập bộ đệm càng tốt (vì cấu trúc giảm có thể phù hợp với bộ đệm dễ dàng hơn), mà không mất nhiều thời gian truy cập ngẫu nhiên.
meneldal

Tôi nghĩ rằng điều này có thể được cải thiện hơn nữa. Tôi đã có thành công trong quá khứ với một vấn đề tương tự nhưng khác biệt trong đó việc khai thác dự đoán chi nhánh giúp ích rất nhiều. Nó có thể giúp phân chia if(code != 3) return code;thànhif(code == 0) return 0; if(code==1) return 1; if(code == 2) return 2;
kutschkem

@kutschkem: trong trường hợp đó, __builtin_expect& co hoặc PGO cũng có thể giúp đỡ.
Matteo Italia

23

Đây là một "bình luận dài" hơn là một câu trả lời cụ thể

Trừ khi dữ liệu của bạn là thứ gì đó nổi tiếng, tôi nghi ngờ bất kỳ ai cũng có thể TRỰC TIẾP câu hỏi của bạn (và tôi không biết bất cứ điều gì phù hợp với mô tả của bạn, nhưng sau đó tôi không biết MỌI THỨ về tất cả các loại mẫu dữ liệu cho tất cả các loại trường hợp sử dụng). Dữ liệu thưa thớt là một vấn đề phổ biến trong điện toán hiệu năng cao, nhưng thông thường là "chúng tôi có một mảng rất lớn, nhưng chỉ có một số giá trị khác không".

Đối với các mẫu không được biết đến như những gì tôi nghĩ là của bạn, sẽ không có ai BIẾT trực tiếp cái nào tốt hơn và nó phụ thuộc vào chi tiết: cách truy cập ngẫu nhiên ngẫu nhiên - là hệ thống truy cập các cụm dữ liệu hoặc là hoàn toàn ngẫu nhiên như từ một bộ tạo số ngẫu nhiên thống nhất. Là dữ liệu bảng hoàn toàn ngẫu nhiên, hoặc có các chuỗi 0 rồi chuỗi 1, với sự phân tán các giá trị khác? Chạy mã hóa độ dài sẽ hoạt động tốt nếu bạn có các chuỗi dài 0 và 1 hợp lý, nhưng sẽ không hoạt động nếu bạn có "bàn cờ 0/1". Ngoài ra, bạn phải giữ một bảng "điểm bắt đầu", để bạn có thể nhanh chóng đến nơi thích hợp một cách hợp lý.

Từ lâu tôi đã biết rằng một số cơ sở dữ liệu lớn chỉ là một bảng lớn trong RAM (dữ liệu thuê bao trao đổi điện thoại trong ví dụ này), và một trong những vấn đề đó là bộ nhớ cache và tối ưu hóa bảng trang trong bộ xử lý là khá vô dụng. Người gọi hiếm khi giống như một người gọi gần đây, rằng không có dữ liệu được tải sẵn dưới bất kỳ hình thức nào, đó hoàn toàn là ngẫu nhiên. Bảng trang lớn là tối ưu hóa tốt nhất cho loại truy cập đó.

Trong rất nhiều trường hợp, thỏa hiệp giữa "tốc độ và kích thước nhỏ" là một trong những điều bạn phải chọn giữa công nghệ phần mềm [trong kỹ thuật khác, nó không nhất thiết phải là sự thỏa hiệp quá nhiều]. Vì vậy, "lãng phí bộ nhớ cho mã đơn giản hơn" thường là lựa chọn ưu tiên. Theo nghĩa này, giải pháp "đơn giản" có khả năng tốt hơn về tốc độ, nhưng nếu bạn sử dụng "tốt hơn" cho RAM, thì tối ưu hóa kích thước của bảng sẽ cung cấp cho bạn hiệu suất đủ và cải thiện tốt về kích thước. Có rất nhiều cách khác nhau để bạn có thể đạt được điều này - như được đề xuất trong một nhận xét, trường 2 bit trong đó hai hoặc ba giá trị phổ biến nhất được lưu trữ và sau đó một số định dạng dữ liệu thay thế cho các giá trị khác - bảng băm sẽ là của tôi Cách tiếp cận đầu tiên, nhưng một danh sách hoặc cây nhị phân cũng có thể hoạt động - một lần nữa, nó phụ thuộc vào kiểu "không phải 0, 1 hoặc 2" của bạn. Một lần nữa, nó phụ thuộc vào cách các giá trị được "phân tán" trong bảng - chúng nằm trong cụm hay chúng có nhiều hơn một mẫu phân bố đồng đều không?

Nhưng một vấn đề với điều đó là bạn vẫn đang đọc dữ liệu từ RAM. Sau đó, bạn đang chi tiêu nhiều mã hơn để xử lý dữ liệu, bao gồm một số mã để đối phó với "đây không phải là giá trị chung".

Vấn đề với hầu hết các thuật toán nén phổ biến là chúng dựa trên các chuỗi giải nén, do đó bạn không thể truy cập chúng một cách ngẫu nhiên. Và chi phí phân chia dữ liệu lớn của bạn thành nhiều phần, giả sử, 256 mục nhập cùng một lúc và giải nén 256 thành một mảng uint8_t, lấy dữ liệu bạn muốn và sau đó vứt bỏ dữ liệu không nén của bạn, rất khó có thể cung cấp cho bạn tốt hiệu suất - tất nhiên, giả sử đó là một số quan trọng.

Cuối cùng, bạn có thể sẽ phải thực hiện một hoặc một vài ý tưởng trong các bình luận / câu trả lời để kiểm tra, xem liệu nó có giúp giải quyết vấn đề của bạn không, hoặc nếu bus bộ nhớ vẫn là yếu tố hạn chế chính.


Cảm ơn! Cuối cùng, tôi chỉ quan tâm đến những gì nhanh hơn khi 100% CPU bận rộn với việc lặp qua các mảng như vậy (các luồng khác nhau trên các mảng khác nhau). Hiện tại, với một uint8_tmảng, băng thông RAM đã bão hòa sau khi ~ 5 luồng hoạt động cùng lúc (trên hệ thống bốn kênh), do đó, việc sử dụng hơn 5 luồng không còn mang lại lợi ích gì nữa. Tôi muốn điều này sử dụng> 10 luồng mà không gặp vấn đề về băng thông RAM, nhưng nếu phía CPU của truy cập trở nên chậm đến mức 10 luồng được thực hiện ít hơn 5 luồng trước đó, thì rõ ràng đó không phải là tiến trình.
JohnAl

@JohnAl Bạn có bao nhiêu lõi? Nếu bạn bị ràng buộc CPU, không có điểm nào có nhiều luồng hơn lõi. Ngoài ra, có lẽ thời gian để xem xét lập trình GPU?
Martin Bonner hỗ trợ Monica

@MartinBonner Hiện tại tôi có 12 chủ đề. Và tôi đồng ý, điều này có thể sẽ chạy rất tốt trên GPU.
JohnAl

2
@ John: Nếu bạn chỉ đang chạy nhiều phiên bản của cùng một quy trình không hiệu quả trên nhiều luồng, bạn sẽ luôn thấy tiến trình hạn chế. Sẽ có những chiến thắng lớn hơn trong việc thiết kế thuật toán của bạn để xử lý song song hơn là điều chỉnh cấu trúc lưu trữ.
Jack Aidley

13

Những gì tôi đã làm trong quá khứ là sử dụng một hashmap ở phía trước một bitet.

Điều này giảm một nửa không gian so với câu trả lời của Matteo, nhưng có thể chậm hơn nếu tra cứu "ngoại lệ" chậm (nghĩa là có nhiều trường hợp ngoại lệ).

Tuy nhiên, thường thì "bộ nhớ cache là vua".


2
Chính xác thì một hashmap sẽ giảm một nửa không gian so với câu trả lời của Matteo như thế nào? Điều gì nên có trong hashmap đó?
JohnAl

1
@JohnAl Sử dụng bitet 1 bit = bitvec thay vì bitvec 2 bit.
o11c

2
@ o11c Tôi không chắc mình có hiểu đúng không. Bạn có nghĩa là có một mảng các giá trị 1 bit trong đó 0có nghĩa là nhìn vàomain_arr1có nghĩa là nhìn vàosec_arr (trong trường hợp mã Matteos)? Điều đó sẽ cần nhiều không gian hơn so với câu trả lời của Matteos, vì một mảng bổ sung của nó. Tôi hoàn toàn không hiểu làm thế nào bạn sẽ làm điều đó chỉ bằng một nửa không gian so với câu trả lời của Matteos.
JohnAl

1
Bạn có thể làm rõ điều này? Bạn tìm kiếm các trường hợp mở rộng trước , và sau đó tìm trong bitmap? Nếu vậy, tôi nghi ngờ việc tra cứu chậm trong hàm băm sẽ lấn át sự tiết kiệm trong việc giảm kích thước của bitmap.
Martin Bonner hỗ trợ Monica

Tôi nghĩ rằng điều này được gọi là hashlinking - nhưng google không có lượt truy cập liên quan nên nó phải là một cái gì đó khác. Cách nó thường hoạt động là nói một mảng byte sẽ giữ các giá trị trong phần lớn trong số đó là, trong khoảng từ 0..254. Sau đó, bạn sẽ sử dụng 255 làm cờ và nếu bạn có một phần tử 255, bạn sẽ tìm giá trị thực trong bảng băm liên quan. Ai đó có thể nhớ những gì nó được gọi là? (Tôi nghĩ rằng tôi đã đọc về nó trong một IBM TR cũ.) Dù sao, bạn cũng có thể sắp xếp nó theo cách @ o11c gợi ý - luôn luôn tìm kiếm trong hàm băm trước, nếu không có, hãy nhìn vào mảng bit của bạn.
davidbak

11

Trừ khi có mẫu cho dữ liệu của bạn, không chắc là có bất kỳ tối ưu hóa tốc độ hoặc kích thước hợp lý nào và - giả sử bạn đang nhắm mục tiêu vào một máy tính bình thường - 10 MB dù sao cũng không phải là vấn đề lớn.

Có hai giả định trong câu hỏi của bạn:

  1. Dữ liệu được lưu trữ kém vì bạn không sử dụng tất cả các bit
  2. Lưu trữ nó tốt hơn sẽ làm cho mọi thứ nhanh hơn.

Tôi nghĩ rằng cả hai giả định này đều sai. Trong hầu hết các trường hợp, cách thích hợp để lưu trữ dữ liệu là lưu trữ biểu diễn tự nhiên nhất. Trong trường hợp của bạn, đây là cái bạn đã sử dụng: một byte cho một số trong khoảng từ 0 đến 255. Bất kỳ biểu diễn nào khác sẽ phức tạp hơn và do đó - tất cả những thứ khác đều bằng nhau - chậm hơn và dễ bị lỗi hơn. Để cần chuyển hướng khỏi nguyên tắc chung này, bạn cần một lý do mạnh mẽ hơn khả năng sáu bit "bị lãng phí" trên 95% dữ liệu của bạn.

Đối với giả định thứ hai của bạn, điều đó sẽ đúng nếu và chỉ khi thay đổi kích thước của mảng dẫn đến việc bỏ lỡ bộ nhớ cache ít hơn đáng kể. Cho dù điều này sẽ xảy ra chỉ có thể được xác định dứt khoát bằng cách lập hồ sơ mã làm việc, nhưng tôi nghĩ rất khó có thể tạo ra sự khác biệt đáng kể. Vì bạn sẽ truy cập ngẫu nhiên vào mảng trong cả hai trường hợp, bộ xử lý sẽ đấu tranh để biết các bit dữ liệu nào cần lưu trong bộ nhớ cache và giữ trong cả hai trường hợp.


8

Nếu dữ liệu và truy cập được phân phối ngẫu nhiên thống nhất, hiệu suất có thể sẽ phụ thuộc vào phần truy cập nào tránh được lỗi bộ nhớ cache cấp ngoài. Tối ưu hóa sẽ yêu cầu biết mảng kích thước nào có thể được cung cấp một cách đáng tin cậy trong bộ đệm. Nếu bộ đệm của bạn đủ lớn để chứa một byte cho mỗi năm ô, cách tiếp cận đơn giản nhất có thể là có một byte giữ năm giá trị được mã hóa ba cơ sở trong phạm vi 0-2 (có 243 kết hợp 5 giá trị, do đó sẽ có vừa vặn trong một byte), cùng với mảng 10.000.000 byte sẽ được truy vấn bất cứ khi nào giá trị cơ sở 3 chỉ ra "2".

Nếu bộ đệm không lớn, nhưng có thể chứa một byte trên 8 ô, thì không thể sử dụng một giá trị byte để chọn trong số tất cả 6.561 kết hợp có thể có của tám giá trị cơ sở 3, nhưng vì hiệu ứng duy nhất của thay đổi 0 hoặc 1 thành 2 sẽ gây ra việc tra cứu không cần thiết, tính chính xác sẽ không yêu cầu hỗ trợ tất cả 6.561. Thay vào đó, người ta có thể tập trung vào 256 giá trị "hữu ích" nhất.

Đặc biệt nếu 0 phổ biến hơn 1 hoặc ngược lại, một cách tiếp cận tốt có thể là sử dụng 217 giá trị để mã hóa các kết hợp 0 ​​và 1 chứa 5 hoặc ít hơn 1, 16 giá trị để mã hóa xxxx0000 qua xxxx1111, 16 để mã hóa 0000xxxx thông qua 1111xxxx và một cho xxxxxxxx. Bốn giá trị sẽ vẫn còn cho bất kỳ mục đích sử dụng nào khác mà người ta có thể tìm thấy. Nếu dữ liệu được phân phối ngẫu nhiên như được mô tả, phần lớn tất cả các truy vấn sẽ truy cập các byte chỉ chứa số 0 và số (trong khoảng 2/3 của tất cả các nhóm tám, tất cả các bit sẽ là số 0 và số 0 và khoảng 7/8 những người sẽ có sáu hoặc ít hơn 1 bit); đại đa số những người không hạ cánh trong một byte chứa bốn x và sẽ có 50% cơ hội hạ cánh xuống 0 hoặc một. Vì vậy, chỉ có khoảng một trong bốn truy vấn sẽ yêu cầu tra cứu mảng lớn.

Nếu dữ liệu được phân phối ngẫu nhiên nhưng bộ đệm không đủ lớn để xử lý một byte trên tám phần tử, người ta có thể thử sử dụng phương pháp này với mỗi byte xử lý hơn tám mục, nhưng trừ khi có độ lệch mạnh về 0 hoặc về 1 , phần giá trị có thể được xử lý mà không phải thực hiện tra cứu trong mảng lớn sẽ co lại khi số lượng được xử lý bởi mỗi byte tăng lên.


7

Tôi sẽ thêm vào @ o11c câu trả lời của , vì từ ngữ của anh ấy có thể hơi khó hiểu. Nếu tôi cần ép bit cuối cùng và chu kỳ CPU, tôi sẽ làm như sau.

Chúng tôi sẽ bắt đầu bằng cách xây dựng một cây tìm kiếm nhị phân cân bằng , chứa 5% trường hợp "cái gì đó khác". Đối với mỗi lần tra cứu, bạn đi bộ cây một cách nhanh chóng: bạn có 10000000 phần tử: 5% trong số đó nằm trong cây: do đó cấu trúc dữ liệu của cây chứa 500000 phần tử. Đi bộ này trong thời gian O (log (n)), cung cấp cho bạn 19 lần lặp. Tôi không phải là chuyên gia về vấn đề này, nhưng tôi đoán có một số triển khai hiệu quả bộ nhớ ngoài kia. Hãy đoán xem:

  • Cây cân bằng, vì vậy vị trí cây con có thể được tính toán (chỉ số không cần phải được lưu trữ trong các nút của cây). Giống như cách một heap (cấu trúc dữ liệu) được lưu trữ trong bộ nhớ tuyến tính.
  • Giá trị 1 byte (2 đến 255)
  • 3 byte cho chỉ mục (10000000 mất 23 bit, phù hợp với 3 byte)

Tổng cộng, 4 byte: 500000 * 4 = 1953 kB. Phù hợp với bộ đệm!

Đối với tất cả các trường hợp khác (0 hoặc 1), bạn có thể sử dụng bitvector. Lưu ý rằng bạn không thể bỏ qua 5% trường hợp khác để truy cập ngẫu nhiên: 1,19 MB.

Sự kết hợp của hai sử dụng khoảng 3.099 MB. Sử dụng kỹ thuật này, bạn sẽ tiết kiệm được hệ số bộ nhớ 3.08.

Tuy nhiên, điều này không đánh bại câu trả lời của @Matteo Italia (sử dụng 2,76 MB), thật đáng tiếc. Có bất cứ điều gì chúng ta có thể làm thêm? Phần tiêu tốn nhiều bộ nhớ nhất là 3 byte chỉ mục trong cây. Nếu chúng tôi có thể giảm xuống còn 2, chúng tôi sẽ tiết kiệm được 488 kB và tổng mức sử dụng bộ nhớ sẽ là: 2.622 MB, nhỏ hơn!

Chung ta se lam như thê nao? Chúng ta phải giảm chỉ mục xuống còn 2 byte. Một lần nữa, 10000000 mất 23 bit. Chúng ta cần có thể giảm 7 bit. Chúng ta chỉ có thể thực hiện điều này bằng cách phân vùng phạm vi 10000000 phần tử thành 2 ^ 7 (= 128) vùng gồm 78125 phần tử. Bây giờ chúng ta có thể xây dựng một cây cân bằng cho từng khu vực này, với trung bình 3906 phần tử. Chọn cây đúng được thực hiện bằng cách phân chia chỉ mục đích đơn giản bằng 2 ^ 7 (hoặc bithift>> 7 ). Bây giờ chỉ số cần thiết để lưu trữ có thể được biểu thị bằng 16 bit còn lại. Lưu ý rằng có một số chi phí cho chiều dài của cây cần được lưu trữ, nhưng điều này là không đáng kể. Cũng lưu ý rằng cơ chế phân tách này làm giảm số lần lặp cần thiết để đi trên cây, giờ đây giảm xuống còn 7 lần lặp, vì chúng tôi đã giảm 7 bit: chỉ còn lại 12 lần lặp.

Lưu ý rằng về mặt lý thuyết bạn có thể lặp lại quá trình cắt bỏ 8 bit tiếp theo, nhưng điều này sẽ yêu cầu bạn tạo 2 ^ 15 cây cân bằng, với trung bình ~ 305 phần tử. Điều này sẽ dẫn đến 2,143 MB, chỉ với 4 lần lặp để đi trên cây, đây là một tốc độ đáng kể, so với 19 lần lặp mà chúng tôi đã bắt đầu.

Như một kết luận cuối cùng: điều này đánh bại chiến lược vectơ 2 bit bằng một chút sử dụng bộ nhớ, nhưng là cả một cuộc đấu tranh để thực hiện. Nhưng nếu nó có thể tạo ra sự khác biệt giữa việc phù hợp với bộ đệm hay không, nó có thể đáng để thử.


1
Nỗ lực dũng cảm!
davidbak

1
Hãy thử điều này: Vì 4% các trường hợp là giá trị 2 ... tạo một tập hợp các trường hợp ngoại lệ (> 1). Tạo một cây như mô tả cho các trường hợp thực sự đặc biệt (> 2). Nếu có trong tập hợp và cây thì sử dụng giá trị trong cây; nếu có trong tập hợp và không phải cây thì sử dụng giá trị 2, nếu không (không có trong tập hợp) tra cứu trong bitvector của bạn. Cây sẽ chỉ chứa 100000 phần tử (byte). Bộ chứa 500000 phần tử (nhưng không có giá trị nào cả). Điều này có làm giảm kích thước trong khi biện minh cho chi phí tăng lên của nó? (100% số lượt tra cứu được thiết lập; 5% số lượt tìm kiếm cũng cần tìm trong cây.)
davidbak

Bạn luôn muốn sử dụng một mảng được sắp xếp CFBS khi bạn có một cây bất biến, do đó không có sự phân bổ cho các nút, chỉ là dữ liệu.
o11c

5

Nếu bạn chỉ thực hiện các thao tác đọc, tốt hơn là không gán giá trị cho một chỉ mục duy nhất mà cho một khoảng các chỉ số.

Ví dụ:

[0, 15000] = 0
[15001, 15002] = 153
[15003, 26876] = 2
[25677, 31578] = 0
...

Điều này có thể được thực hiện với một cấu trúc. Bạn cũng có thể muốn định nghĩa một lớp tương tự như thế này nếu bạn thích cách tiếp cận OO.

class Interval{
  private:
    uint32_t start; // First element of interval
    uint32_t end; // Last element of interval
    uint8_t value; // Assigned value

  public:
    Interval(uint32_t start, uint32_t end, uint8_t value);
    bool isInInterval(uint32_t item); // Checks if item lies within interval
    uint8_t getValue(); // Returns the assigned value
}

Bây giờ bạn chỉ cần lặp lại một danh sách các khoảng và kiểm tra xem chỉ mục của bạn có nằm trong một trong số chúng có thể tốn ít bộ nhớ hơn trung bình không nhưng tốn nhiều tài nguyên CPU hơn.

Interval intervals[INTERVAL_COUNT];
intervals[0] = Interval(0, 15000, 0);
intervals[1] = Interval(15001, 15002, 153);
intervals[2] = Interval(15003, 26876, 2);
intervals[3] = Interval(25677, 31578, 0);
...

uint8_t checkIntervals(uint32_t item)

    for(int i=0; i<INTERVAL_COUNT-1; i++)
    {
        if(intervals[i].isInInterval(item) == true)
        {
            return intervals[i].getValue();
        }
    }
    return DEFAULT_VALUE;
}

Nếu bạn sắp xếp các khoảng thời gian theo kích thước giảm dần, bạn sẽ tăng xác suất rằng mặt hàng bạn đang tìm kiếm được tìm thấy sớm sẽ làm giảm thêm mức sử dụng tài nguyên CPU và bộ nhớ trung bình.

Bạn cũng có thể xóa tất cả các khoảng có kích thước 1. Đặt các giá trị tương ứng vào bản đồ và chỉ kiểm tra chúng nếu mục bạn đang tìm kiếm không tìm thấy trong các khoảng. Điều này cũng sẽ tăng hiệu suất trung bình một chút.


4
Ý tưởng thú vị (+1) nhưng tôi hơi nghi ngờ rằng nó sẽ biện minh cho chi phí trừ khi có rất nhiều hoạt động dài 0 và / hoặc dài 1 giây. Trong thực tế, bạn đang đề xuất sử dụng mã hóa dữ liệu thời lượng dài. Nó có thể tốt trong một số tình huống nhưng có lẽ không phải là một cách tiếp cận chung tốt cho vấn đề này.
John Coleman

Đúng. Đặc biệt đối với truy cập ngẫu nhiên, điều này gần như chắc chắn chậm hơn một mảng đơn giản hoặc unt8_t, ngay cả khi nó chiếm ít bộ nhớ hơn.
rẽ trái

4

Lâu lắm rồi, tôi mới nhớ được ...

Trong trường đại học, chúng tôi có một nhiệm vụ để tăng tốc chương trình theo dõi tia, phải đọc bằng thuật toán nhiều lần từ các mảng bộ đệm. Một người bạn nói với tôi rằng luôn sử dụng RAM-đọc là bội số của 4Bytes. Vì vậy, tôi đã thay đổi mảng từ một mẫu [x1, y1, z1, x2, y2, z2, ..., xn, yn, zn] thành một mẫu của [x1, y1, z1,0, x2, y2, z2 , 0, ..., xn, yn, zn, 0]. Có nghĩa là tôi thêm một trường trống sau mỗi tọa độ 3D. Sau một số thử nghiệm hiệu suất: Nó đã nhanh hơn. Câu chuyện dài quá ngắn: Đọc nhiều 4 byte từ mảng của bạn từ RAM và cũng có thể từ vị trí bắt đầu bên phải, vì vậy bạn đọc một cụm nhỏ trong đó chỉ mục tìm kiếm nằm trong đó và đọc chỉ mục được tìm kiếm từ cụm nhỏ này trong cpu. (Trong trường hợp của bạn, bạn sẽ không cần chèn các trường điền, nhưng khái niệm này phải rõ ràng)

Có lẽ các bội số khác cũng có thể là chìa khóa trong các hệ thống mới hơn.

Tôi không biết nếu điều này sẽ làm việc trong trường hợp của bạn, vì vậy nếu nó không hoạt động: Xin lỗi. Nếu nó hoạt động tôi sẽ rất vui khi nghe về một số kết quả thử nghiệm.

PS: Ồ và nếu có bất kỳ mẫu truy cập hoặc các chỉ mục truy cập gần đó, bạn có thể sử dụng lại cụm được lưu trữ.

PPS: Có thể là, nhiều yếu tố giống như 16Bytes hoặc một cái gì đó tương tự, cách đây quá lâu, tôi có thể nhớ chính xác.


Có lẽ bạn đang nghĩ về các bộ đệm, thường là 32 hoặc 64 byte, nhưng điều đó sẽ không giúp ích nhiều ở đây vì việc truy cập là ngẫu nhiên.
Surt

3

Nhìn vào điều này, bạn có thể chia dữ liệu của mình, ví dụ:

  • một bitet được lập chỉ mục và biểu thị giá trị 0 (std :: vector sẽ hữu ích ở đây)
  • một bitet được lập chỉ mục và đại diện cho giá trị 1
  • một std :: vector cho các giá trị của 2, chứa các chỉ mục tham chiếu đến giá trị này
  • bản đồ cho các giá trị khác (hoặc std :: vector>)

Trong trường hợp này, tất cả các giá trị xuất hiện cho đến một chỉ mục nhất định, do đó bạn thậm chí có thể xóa một trong các bit và biểu thị giá trị khi nó bị thiếu trong các chỉ mục khác.

Điều này sẽ giúp bạn tiết kiệm một số bộ nhớ cho trường hợp này, mặc dù sẽ làm cho trường hợp xấu nhất trở nên tồi tệ hơn. Bạn cũng sẽ cần nhiều năng lượng CPU hơn để thực hiện tra cứu.

Hãy chắc chắn để đo lường!


1
Một bitet cho những người / số không. Một bộ chỉ số cho twos. Và một mảng kết hợp thưa thớt cho phần còn lại.
Màu đỏ.

Đó là tóm tắt ngắn gọn
JVApen

Hãy để OP biết các điều khoản, để anh ta có thể tìm kiếm các triển khai thay thế của từng điều khoản.
Màu đỏ.

2

Giống như Mats đề cập trong câu trả lời nhận xét của anh ấy, thật khó để nói đâu là giải pháp tốt nhất mà không biết cụ thể bạn có loại dữ liệu nào (ví dụ: có những dòng 0 dài, v.v.) và kiểu truy cập của bạn trông như thế nào like ("ngẫu nhiên" có nghĩa là "ở khắp mọi nơi" hoặc chỉ "không hoàn toàn theo kiểu hoàn toàn tuyến tính" hoặc "mỗi giá trị chính xác một lần, chỉ là ngẫu nhiên" hoặc ...).

Điều đó nói rằng, có hai cơ chế đến với tâm trí:

  • Mảng bit; tức là, nếu bạn chỉ có hai giá trị, bạn có thể nén mảng của mình theo hệ số 8; nếu bạn có 4 giá trị (hoặc "3 giá trị + mọi thứ khác"), bạn có thể nén theo hệ số hai. Điều này có thể không đáng để gặp rắc rối và sẽ cần điểm chuẩn, đặc biệt là nếu bạn có các mẫu truy cập thực sự ngẫu nhiên thoát khỏi bộ nhớ cache của bạn và do đó không thay đổi thời gian truy cập.
  • (index,value)hoặc (value,index)bảng. Tức là, có một bảng rất nhỏ cho trường hợp 1%, có thể một bảng cho trường hợp 5% (chỉ cần lưu trữ các chỉ mục vì tất cả đều có cùng giá trị) và một mảng bit nén lớn cho hai trường hợp cuối cùng. Và với "bảng", ý tôi là thứ gì đó cho phép tra cứu tương đối nhanh; tức là, có thể là hàm băm, cây nhị phân, v.v., tùy thuộc vào những gì bạn có sẵn và nhu cầu thực tế của bạn. Nếu những phụ đề này phù hợp với bộ nhớ cache cấp 1/2 của bạn, bạn có thể gặp may mắn.

1

Tôi không rành lắm về C, nhưng trong C ++, bạn có thể sử dụng char không dấu để biểu diễn một số nguyên trong phạm vi 0 - 255.

So với int thông thường (một lần nữa, tôi đến từ thế giới JavaC ++ ) trong đó yêu cầu 4 byte (32 bit), một char không dấu yêu cầu 1 byte (8 bit). do đó, nó có thể giảm 75% tổng kích thước của mảng.


Đó có lẽ là trường hợp đã sử dụng uint8_t - 8 có nghĩa là 8 bit.
Peter Mortensen

-4

Bạn đã mô tả ngắn gọn tất cả các đặc điểm phân phối của mảng của bạn; quăng mảng .

Bạn có thể dễ dàng thay thế mảng bằng một phương thức ngẫu nhiên tạo ra đầu ra xác suất tương tự như mảng.

Nếu tính nhất quán (tạo ra cùng một giá trị cho cùng một chỉ mục ngẫu nhiên), hãy xem xét sử dụng bộ lọc nở và / hoặc bản đồ băm để theo dõi các lần truy cập lặp lại. Tuy nhiên, nếu truy cập mảng của bạn thực sự là ngẫu nhiên, điều này hoàn toàn không cần thiết.


18
Tôi nghi ngờ "truy cập ngẫu nhiên" đã được sử dụng ở đây để chỉ ra rằng các truy cập là không thể đoán trước, không phải là chúng thực sự ngẫu nhiên. (tức là nó được dùng theo nghĩa "các tệp truy cập ngẫu nhiên")
Michael Kay

Vâng, đó là có khả năng. OP không rõ ràng, tuy nhiên. Nếu truy cập của OP theo bất kỳ cách nào không phải là ngẫu nhiên, thì một số dạng của mảng thưa thớt được chỉ định, theo các câu trả lời khác.
Dúthomhas

1
Tôi nghĩ rằng bạn có một điểm ở đó, vì OP chỉ ra rằng anh ta sẽ lặp lại toàn bộ mảng theo thứ tự ngẫu nhiên. Đối với trường hợp chỉ cần quan sát phân phối, đây là một câu trả lời tốt.
Ingo Schalk-Schupp
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.