2018年2月16日 星期五

Halide Tutorial 非官方中文翻譯 - Part 4

Halide Tutorial 非官方中譯 - Part 1
Halide Tutorial 非官方中譯 - Part 2
Halide Tutorial 非官方中譯 - Part 3

==

Lesson 9 - Multi-pass Funcs, update definitions, and reductions


// Halide tutorial lesson 9:


// 宣告後續會使用的變數
Var x("x"), y("y");

// 讀取灰階影像做為輸入影像
Buffer input = load_image("images/gray.png");

// 能夠定義 multiple  pass Func. 首先定義玩具範本
{
// 第一的定義如同先前所看過的
// - 一個自 Var 到 Expr 的 mapping
Func f;
f(x, y) = x + y;
// 我們稱這個定義為"純"(pure)定義

// 但之後的定義包含在等號兩邊需要計算的表示式. 最簡單的範例是只修改一個點
f(3, 7) = 42;

// 我們稱這些額外的定義為"更新"(update)定義, 或是"歸納"(reduction)定義.
// 一個歸納定義是一個遞迴地參考自身同位置目前數值的更新定義
f(x, y) = f(x, y) + 17;

// 若限縮更新範圍在單一橫列, 就能遞迴參考在同一直行的數值
f(x, 3) = f(x, 0) * f(x, 10);
// 同樣地, 若限縮更新範圍在單一直行, 就能遞迴參考在同一橫列的數值
f(0, y) = f(0, y) / f(3, y);

// 通則是: 每個用在一個更新定義中的變數, 必須在等號兩邊出現在純定義中對應不變的位置// 因此下列皆為合法的定義
f(x, 17) = x + 8;
f(0, y) = y * 8;
f(x, x + 1) = x + 8;
f(y/2, y) = f(0, y) * 17;

// 但下列都會產生錯誤

// f(x, 0) = f(x + 1, 0);
// 右邊的 f 的第一個參數應為 x 而非 x+1

// f(y, y + 1) = y + 8;
// 左邊 f 的第二個參數應為 y 而非 y+1

// f(y, x) = y - x;
// 左邊的 f 的參數位置錯誤

// f(3, 4) = x + y;
// 右邊出現了變數, 然而左邊並有

// 實現這函數僅是確認它編譯過,
// 從第二個之後的定義將強迫在長大於寬的區域上實現
f.realize(100, 101);

// 對每個 f 的實現而言, 每一步都在下一個開始之前完成.
// 接著透過追蹤簡單例子的讀取與寫入
Func g("g");
g(x, y) = x + y;   // Pure definition
g(2, 1) = 42;      // First update definition
g(x, 0) = g(x, 1); // Second update definition

g.trace_loads();
g.trace_stores();

g.realize(4, 4);
// Click to show output ...

// 下圖為視覺化結果


// 讀取 log 後可以觀察到每個 pass 是依序填入
// 等義 C code 如下:
int result[4][4];
// Pure definition
for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        result[y][x] = x + y;
    }
}
// 第一個更新定義
result[1][2] = 42;
// 第二個更新定義
for (int x = 0; x < 4; x++) {
    result[0][x] = result[1][x];
}
}

// 將更新定義放入 loops
{
// 首先用此純定義
Func f;
f(x, y) = (x + y)/100.0f;

// 接著想要能更新前 50 列
// 當然能夠直接加 50 個更新定義

// f(x, 0) = f(x, 0) * f(x, 0);
// f(x, 1) = f(x, 1) * f(x, 1);
// f(x, 2) = f(x, 2) * f(x, 2);
// ...
// f(x, 49) = f(x, 49) * f(x, 49);

// 或等義於在 C++ 程式碼加入在編譯時期的 loop
// for (int i = 0; i < 50; i++) {
//   f(x, i) = f(x, i) * f(x, i);
// }

// 但更具可管理性與更加彈性的方式是將這 loop 放入編譯所產生的程式碼中
// 這方式是藉由定義一個 "reduction domain" 並且在一個更新定義中使用所達到
RDom r(0, 50);
f(x, r) = f(x, r) * f(x, r);
Buffer<float> halide_result = f.realize(100, 100);

// 下圖為視覺化結果


 Your browser does not support the video tag :(

// 等義的 C code:
float c_result[100][100];
for (int y = 0; y < 100; y++) {
    for (int x = 0; x < 100; x++) {
        c_result[y][x] = (x + y)/100.0f;
    }
}
for (int x = 0; x < 100; x++) {
    for (int r = 0; r < 50; r++) {
        // reduction domain 的 loop 發生在任何純定義中作為更新步驟變數的 loop之中
        c_result[r][x] = c_result[r][x] * c_result[r][x];
    }
}

// 檢查結果是否一致
for (int y = 0; y < 100; y++) {
    for (int x = 0; x < 100; x++) {
        if (fabs(halide_result(x, y) - c_result[y][x]) > 0.01f) {
            printf("halide_result(%d, %d) = %f instead of %f\n",
                   x, y, halide_result(x, y), c_result[y][x]);
            return -1;
        }
    }
}
}

// 接著以實際用途來應用更新定義: 計算 histogram
{
// 一些影像上的操作無法單以自輸出座標到所寫入數值的純函數所表示
// 經典的例子是 histogram 的計算.
// 最直接的方式是不斷地在輸入影像上, 更新 histogram bucket
// 在 Halide 中如此實作:
Func histogram("histogram");

// 所以的 Histogram buckets 預設為 0
histogram(x) = 0;

// 在輸入影像上定義一個多維的 reduction domain
RDom r(0, input.width(), 0, input.height());

// 對於 reduction domain 的每個點, 將對應輸入影像點的數值的 histogram bucket 增加
histogram(input(r.x, r.y)) += 1;

Buffer halide_result = histogram.realize(256);

// 等義的 C code:
int c_result[256];
for (int x = 0; x < 256; x++) {
    c_result[x] = 0;
}
for (int r_y = 0; r_y < input.height(); r_y++) {
    for (int r_x = 0; r_x < input.width(); r_x++) {
        c_result[input(r_x, r_y)] += 1;
    }
}

// 檢查結果
for (int x = 0; x < 256; x++) {
    if (c_result[x] != halide_result(x)) {
        printf("halide_result(%d) = %d instead of %d\n",
               x, halide_result(x), c_result[x]);
        return -1;
    }
}
}

// 更新步驟的排程
{
// 如同以往, 更新步驟中的純變數依然可以 parallelized, vectorized 與 split

// 對 reduction domain 中的變數作 vectorizing, splitting 與 parallelize  較為棘手
// 後續的章節會說明

// 考量以下定義:
Func f;
f(x, y) = x*y;
// 將第0列的數值設為第8列的數值
f(x, 0) = f(x, 8);
// 將第1行的數值設為第8行的數值加上2
f(0, y) = f(8, y) + 2;

// 在每個 stage 中的純變數能獨立地被排程.
// 為了控制純定義, 能如同先前般地排程.
// 下列的程式碼對純定義做 vectorization 與 parallelization
f.vectorize(x, 4).parallel(y);

// 為了排程目的這裡使用 Func::update(int)來取得更新步驟所用的 handle
// 接著沿著 x 作 vectorization.
// 由於更新定義沒有使用到 y 變數, 這裡無法對 y 作操作
f.update(0).vectorize(x, 4);

// 對於第二更新步驟作以 4 做分割大小的平行化
Var yo, yi;
f.update(1).split(y, yo, yi, 4).parallel(yo);

Buffer halide_result = f.realize(16, 16);

// 下圖為視覺化結果

 Your browser does not support the video tag :(

// 以下為等義的 C code:
int c_result[16][16];

// 純定義的步驟, x  方向做 vectorization, 於 y 方向做 parallelization
for (int y = 0; y < 16; y++) { // Should be a parallel for loop
    for (int x_vec = 0; x_vec < 4; x_vec++) {
        int x[] = {x_vec*4, x_vec*4+1, x_vec*4+2, x_vec*4+3};
        c_result[y][x[0]] = x[0] * y;
        c_result[y][x[1]] = x[1] * y;
        c_result[y][x[2]] = x[2] * y;
        c_result[y][x[3]] = x[3] * y;
    }
}

// 第一次更新, 沿著 x 方向做 vectorization
for (int x_vec = 0; x_vec < 4; x_vec++) {
    int x[] = {x_vec*4, x_vec*4+1, x_vec*4+2, x_vec*4+3};
    c_result[0][x[0]] = c_result[8][x[0]];
    c_result[0][x[1]] = c_result[8][x[1]];
    c_result[0][x[2]] = c_result[8][x[2]];
    c_result[0][x[3]] = c_result[8][x[3]];
}

// 第二次更新, 以分割大小為 4 的方式, 沿著 y 做 parallelization
for (int yo = 0; yo < 4; yo++) { // Should be a parallel for loop
    for (int yi = 0; yi < 4; yi++) {
        int y = yo*4 + yi;
        c_result[y][0] = c_result[y][8] + 2;
    }
}

// 確認 C 與 Halide 結果一致:
for (int y = 0; y < 16; y++) {
    for (int x = 0; x < 16; x++) {
        if (halide_result(x, y) != c_result[y][x]) {
            printf("halide_result(%d, %d) = %d instead of %d\n",
                   x, y, halide_result(x, y), c_result[y][x]);
            return -1;
        }
    }
}
}

// 這些僅涵蓋如何在單個 Func 的更新步驟中的變數做排程
// 但像是 producer-consumer 牽涉到 compute_at 與 store_at 的關係又如何?
// 接著透過 producer-consumer 成對的方式, 來確認以 producer 作歸納的方式
{
// 由於一個更新是在儲存陣列上做的 multiple pass
// 因此將其作為 inline 沒有意義. 因此預設排程作可能上最為接近的動作
// 是在 consumer 最內層中計算這些數值
// 考慮此無關緊要的例子:
Func producer, consumer;
producer(x) = x*2;
producer(x) += 10;
consumer(x) = 2 * producer(x);
Buffer<int> halide_result = consumer.realize(10);

// 以下為視覺化結果




// 等義的 C code 如下:
int c_result[10];
for (int x = 0; x < 10; x++)  {
    int producer_storage[1];
    // 純定義計算 producer 的步驟
    producer_storage[0] = x * 2;
    // producer 的更新步驟
    producer_storage[0] = producer_storage[0] + 10;
    // 純定義計算 consumer 的步驟
    c_result[x] = 2 * producer_storage[0];
}

// 確認結果一致
for (int x = 0; x < 10; x++) {
    if (halide_result(x) != c_result[x]) {
        printf("halide_result(%d) = %d instead of %d\n",
               x, halide_result(x), c_result[x]);
        return -1;
    }
}

// 對於其他 compute_at/store_at 選項,
// 歸納會置於 consumer 巢狀 loop 中某處預期的位置
}

// 接著考量在 producer-consumer 組合中, 以 consumer 做歸納
// 這會有些複雜
{
{
    // Case 1: The consumer 在純定義步驟中參考 producer
    Func producer, consumer;
    // 這裡 producer 也是純定義.
    producer(x) = x*17;
    consumer(x) = 2 * producer(x);
    consumer(x) += 50;

    // 在這 case 中有效的 producer 排程包含預設排程 - inline 以及:    //
    // 1) producer.compute_at(x), 將 producer 計算置於 consumer 純定義中 x loop 內
    //    // 2) producer.compute_root(), 事先計算所有的 producer
    //
    // 3) producer.store_root().compute_at(x), 在 consumer 做外層的 loop 配置空間
    // 但在所需要數值的 loop 中填入
    //
    // 這裡使用選項 1

    producer.compute_at(consumer, x);

    Buffer<int> halide_result = consumer.realize(10);
    // 下圖為視覺化結果

    

    // 等義 C code:
    int c_result[10];
    // 計算 consumer 的純定義步驟
    for (int x = 0; x < 10; x++)  {
        // producer 的純定義步驟
        int producer_storage[1];
        producer_storage[0] = x * 17;
        c_result[x] = 2 * producer_storage[0];
    }
    // consumer 的更新步驟
    for (int x = 0; x < 10; x++) {
        c_result[x] += 50;
    }

    // 所有的純定義步驟都在更新步驟前計算
    // 所以會有著兩個在 x 方向上的 loop

    // 確認結果一致
    for (int x = 0; x < 10; x++) {
        if (halide_result(x) != c_result[x]) {
            printf("halide_result(%d) = %d instead of %d\n",
                   x, halide_result(x), c_result[x]);
            return -1;
        }
    }
}


{
    // Case 2: consumer 僅在更新步驟中參考 producer
    Func producer, consumer;
    producer(x) = x * 17;
    consumer(x) = 100 - x * 10;
    consumer(x) += producer(x);    // 相同地, 在 consumer 每個 x 座標中計算 producer
    // producer 程式碼會置於consumer 更新步驟中
    // 因為那是唯一使用到 producer 的步驟
    producer.compute_at(consumer, x);    // 注意, 這並不表示:
    //
    // producer.compute_at(consumer.update(0), x).
    //
    // 排程是藉由 Func 中的 Vars 所達成
    // 而 Func 內的 Vars 會在純定義與更新步驟中所共用

    Buffer<int> halide_result = consumer.realize(10);

    // 下圖為視覺化結果


    // 等義的 C code:
    int c_result[10];
    // consumer 的純定義步驟
    for (int x = 0; x < 10; x++)  {
        c_result[x] = 100 - x * 10;
    }
    // consumer 的更新步驟
    for (int x = 0; x < 10; x++) {
        // producer 的純定義步驟
        int producer_storage[1];
        producer_storage[0] = x * 17;
        c_result[x] += producer_storage[0];
    }


    // 確認結果一致
    for (int x = 0; x < 10; x++) {
        if (halide_result(x) != c_result[x]) {
            printf("halide_result(%d) = %d instead of %d\n",
                   x, halide_result(x), c_result[x]);
            return -1;
        }
    }
}


{
    // Case 3: consumer 在使用共變數的多個步驟中參考使用 producer
    Func producer, consumer;
    producer(x) = x * 17;
    consumer(x) = 170 - producer(x);
    consumer(x) += producer(x)/2;
    // 同樣地, 在 consumer 中每個 x 座標計算 producer
    // 這會把 producer 程式碼置於 consumer 的純定義與更新步驟
    // 因此最後的結果是兩個個別實現, 並有著重複的冗工
    producer.compute_at(consumer, x);

    Buffer<int> halide_result = consumer.realize(10);

    // 下圖為視覺化結果


    // 等義的 C code:
    int c_result[10];
    // consumer 的純定義步驟
    for (int x = 0; x < 10; x++)  {
        // producer 的純定義步驟
        int producer_storage[1];
        producer_storage[0] = x * 17;
        c_result[x] = 170 - producer_storage[0];
    }
    // consumer 的更新步驟
    for (int x = 0; x < 10; x++) {
        // 複製 producer 純定義步驟
        int producer_storage[1];
        producer_storage[0] = x * 17;
        c_result[x] += producer_storage[0]/2;
    }

    // 確認結果一致
    for (int x = 0; x < 10; x++) {
        if (halide_result(x) != c_result[x]) {
            printf("halide_result(%d) = %d instead of %d\n",
                   x, halide_result(x), c_result[x]);
            return -1;
        }
    }
}

{
    // Case 4: consumer 在不使用共用變數的多個步驟參考使用 producer
    Func producer, consumer;
    producer(x, y) = (x * y) / 10 + 8;
    consumer(x, y) = x + y;
    consumer(x, 0) = producer(x, x);
    consumer(0, y) = producer(y, 9-y);    // 在這個 case 中因為會造成其中一個 producer 錯誤,
    // 因此 producer.compute_at(consumer, x) 與 producer.compute_at(consumer, y) 都無法使用
    // 所以必須使用 producer.compute_root() 來 inline    // 這裡想在 conumer 兩個更新步驟的內層 loop 中作 producer 計算
    // Halide 不允許在單一 Func 上套用不同的排程steps.
    // 但能夠藉由 producer 產生兩個 wrapper 並且在 wrapper 上作排程

    // Attempt 2:
    Func producer_1, producer_2, consumer_2;
    producer_1(x, y) = producer(x, y);
    producer_2(x, y) = producer(x, y);

    consumer_2(x, y) = x + y;
    consumer_2(x, 0) += producer_1(x, x);
    consumer_2(0, y) += producer_2(y, 9-y);

    // wrapper 函數提供兩個個別的 producer handle
    // 因此能夠以不同方式來排程
    producer_1.compute_at(consumer_2, x);
    producer_2.compute_at(consumer_2, y);

    Buffer<int> halide_result = consumer_2.realize(10, 10);
    // 下圖為視覺化結果


    // 等義的 C code:
    int c_result[10][10];
    // consumer 純定義的步驟
    for (int y = 0; y < 10; y++) {
        for (int x = 0; x < 10; x++) {
            c_result[y][x] = x + y;
        }
    }
    // 第一個 consumer 更新步驟
    for (int x = 0; x < 10; x++) {
        int producer_1_storage[1];
        producer_1_storage[0] = (x * x) / 10 + 8;
        c_result[0][x] += producer_1_storage[0];
    }
    // 第二個 consumer 更新步驟
    for (int y = 0; y < 10; y++) {
        int producer_2_storage[1];
        producer_2_storage[0] = (y * (9-y)) / 10 + 8;
        c_result[y][0] += producer_2_storage[0];
    }

    // 確認結果一致
    for (int y = 0; y < 10; y++) {
        for (int x = 0; x < 10; x++) {
            if (halide_result(x, y) != c_result[y][x]) {
                printf("halide_result(%d, %d) = %d instead of %d\n",
                       x, y, halide_result(x, y), c_result[y][x]);
                return -1;
            }
        }
    }
}


{
    // Case 5: 在一個 consumer 特定變數的歸納範圍排程下排程 producer
    // 這裡不只是在 consumer 純定義變數上限制 producer 的排程W
    // 若 producer 只用在特定歸納區域(RDom), 依然可以對其做排程
    Func producer, consumer;

    RDom r(0, 5);
    producer(x) = x % 8;
    consumer(x) = x + 10;
    consumer(x) += r + producer(x + r);

    producer.compute_at(consumer, r);

    Buffer<int> halide_result = consumer.realize(10);
    // 下圖為視覺化結果

    

    // 等義的 C CODE:
    int c_result[10];
    // consumer 純定義步驟
    for (int x = 0; x < 10; x++)  {
        c_result[x] = x + 10;
    }
    // consumer 的更新步驟
    for (int x = 0; x < 10; x++) {
        // 歸納區域的 loop 總是內層 loop.
        for (int r = 0; r < 5; r++) {
            // 已經將 producer 的儲存空間與計算做排程於此
            // 這裡只需要單一數值
            int producer_storage[1];
            // producer 純定義步驟
            producer_storage[0] = (x + r) % 8;

            // 在 consumer 更新步驟中使用 producer
            c_result[x] += r + producer_storage[0];
        }
    }

    // 確認結果一致
    for (int x = 0; x < 10; x++) {
        if (halide_result(x) != c_result[x]) {
            printf("halide_result(%d) = %d instead of %d\n",
                   x, halide_result(x), c_result[x]);
            return -1;
        }
    }
}
}

// 在 producer-consumer 中使用歸納的實際的例子
{
// 歸納的預設排程對於類似 convolution 的運算是不錯的.
// 像是下列以 clamp-to-edge 邊界條件於測試影像上計算 5x5 box-blur

// 首先加入邊界條件
Func clamped = BoundaryConditions::repeat_edge(input);

// 定義一個自 (-2, -2) 起始的 5x5 box
RDom r(-2, 5, -2, 5);

// 計算所有 5x5 pixel 的總和
Func local_sum;
local_sum(x, y) = 0; // 以 32-bit 整數計算總和
local_sum(x, y) += clamped(x + r.x, y + r.y);

// 將總和除以 25 來取得平均數
Func blurry;
blurry(x, y) = cast(local_sum(x, y) / 25);

Buffer<uint8_t> halide_result = blurry.realize(input.width(), input.height());

// 預設排程會將 'clamped' 以 inline 置入更新步驟 'local_sump' 中
// 因為 clamped 只有純定義, 因此預設排程為 fully-inlined.
// 接著會計算每個 x 座標的模糊所需的 local_sum,
// 因為歸納的預設排程是最內層計算.
// 以下為等義的 C code:

Buffer<uint8_t> c_result(input.width(), input.height());
for (int y = 0; y < input.height(); y++) {
    for (int x = 0; x < input.width(); x++) {
        int local_sum[1];
        // local_sum 的純定義步驟計算
        local_sum[0] = 0;
        // local_sum 的更新步驟
        for (int r_y = -2; r_y <= 2; r_y++) {
            for (int r_x = -2; r_x <= 2; r_x++) {
                // 邊界座標的 inlin 置於更新步驟中.
                int clamped_x = std::min(std::max(x + r_x, 0), input.width()-1);
                int clamped_y = std::min(std::max(y + r_y, 0), input.height()-1);
                local_sum[0] += input(clamped_x, clamped_y);
            }
        }
        // 模糊的純定義
        c_result(x, y) = (uint8_t)(local_sum[0] / 25);
    }
}

// 確認結果一致
for (int y = 0; y < input.height(); y++) {
    for (int x = 0; x < input.width(); x++) {
        if (halide_result(x, y) != c_result(x, y)) {
            printf("halide_result(%d, %d) = %d instead of %d\n",
                   x, y, halide_result(x, y), c_result(x, y));
            return -1;
        }
    }
}
}

// 歸納輔助 (reduction helpers)
{
// 在 Halide.h 中提供了計算小型歸納與最內層排程的數個歸納輔助.
// 最有用的一個是 "sum"
Func f1;
RDom r(0, 100);
f1(x) = sum(r + x) * 7;

// Sum 建立一個小型匿名 Func 來作歸納. 其等義於:
Func f2;
Func anon;
anon(x) = 0;
anon(x) += r + x;
f2(x) = anon(x) * 7;

// 即便透過 f1 參考一個歸納範圍, 它是個純函數.
// 歸納範圍已經被涵蓋來定義的內部匿名歸納

Buffer<int> halide_result_1 = f1.realize(10);
Buffer<int> halide_result_2 = f2.realize(10);

// 等義的 C code:
int c_result[10];
for (int x = 0; x < 10; x++) {
    int anon[1];
    anon[0] = 0;
    for (int r = 0; r < 100; r++) {
        anon[0] += r + x;
    }
    c_result[x] = anon[0] * 7;
}

// 確認結果是否一致
for (int x = 0; x < 10; x++) {
    if (halide_result_1(x) != c_result[x]) {
        printf("halide_result_1(%d) = %d instead of %d\n",
               x, halide_result_1(x), c_result[x]);
        return -1;
    }
    if (halide_result_2(x) != c_result[x]) {
        printf("halide_result_2(%d) = %d instead of %d\n",
               x, halide_result_2(x), c_result[x]);
        return -1;
    }
}
}

// 使用歸納輔助的複雜範例
{
// 其他歸納輔助包含了 "product", "minimum", "maximum", "argmin" 與 "argmax"
// 使用 argmin 與 argmax 需要了解下一節所介紹的 tuples
// 這裡使用 minimum 與 maximum 來計算灰階影像的局部擴散

// 首先加入輸入的邊界條件
Func clamped;
Expr x_clamped = clamp(x, 0, input.width()-1);
Expr y_clamped = clamp(y, 0, input.height()-1);
clamped(x, y) = input(x_clamped, y_clamped);

RDom box(-2, 5, -2, 5);
// 計算局部最大值減去局部最小值
Func spread;
spread(x, y) = (maximum(clamped(x + box.x, y + box.y)) -
                minimum(clamped(x + box.x, y + box.y)));

// 以 32 scanline 做分割來計算
Var yo, yi;
spread.split(y, yo, yi, 32).parallel(yo);

// 在每個 32 scanline 分割中沿著 x 方向作 vectorization.
// 相關不明確的 vectorization 是在 spread 中沿著 x 方向作計算 
// 其中包含了亦已 vectorize 的 minimum 與 maximum 輔助函數.
spread.vectorize(x, 16);

// 當需要在 circular buffer 的數值時, 藉由 padding 來套用邊界條件
clamped.store_at(spread, yo).compute_at(spread, yi);

Buffer<uint8_t> halide_result = spread.realize(input.width(), input.height());

// 等義的 C code 幾乎太過可怕而難以思考 (花了作者很長時間除錯)
// 這次為了同時測量 Halide 與 C 版本,
// 將使用 SSE intrinsics 來做 vectorization
// 以及 openmp 來平行化 loop (需要使用 -fopenmp 來編譯, 以取得正確的時間)
#ifdef __SSE2__

// 不用將配置輸出 buffer 的時間計入
Buffer<uint8_t> c_result(input.width(), input.height());

#ifdef _OPENMP
double t1 = current_time();
#endif

// 執行 100 次來計算平均時間
for (int iters = 0; iters < 100; iters++) {

    #pragma omp parallel for
    for (int yo = 0; yo < (input.height() + 31)/32; yo++) {
        int y_base = std::min(yo * 32, input.height() - 32);

        // 計算被限制在大小為 8 的 circular buffer
        // (大於 5 的最小 2 的冪次方). Each thread
        // 每個執行緒需要自己的配置, 因此在此處理

        int clamped_width = input.width() + 4;
        uint8_t *clamped_storage = (uint8_t *)malloc(clamped_width * 8);

        for (int yi = 0; yi < 32; yi++) {
            int y = y_base + yi;

            uint8_t *output_row = &c_result(0, y);

            // 計算此 scanline 的邊界限制, 並且跳過此 slice 已計算的列
            int min_y_clamped = (yi == 0) ? (y - 2) : (y + 2);
            int max_y_clamped = (y + 2);
            for (int cy = min_y_clamped; cy <= max_y_clamped; cy++) {
                // 使用 bitmasking 方式計算要填入 circular buffer 的哪一列:
                uint8_t *clamped_row =
                    clamped_storage + (cy & 7) * clamped_width;

                // 藉由對 y 座標 clamp 來計算要讀取輸入的哪一列:
                int clamped_y = std::min(std::max(cy, 0), input.height()-1);
                uint8_t *input_row = &input(0, clamped_y);

                // 搭配 padding 填入
                for (int x = -2; x < input.width() + 2; x++) {
                    int clamped_x = std::min(std::max(x, 0), input.width()-1);
                    *clamped_row++ = input_row[clamped_x];
                }
            }

            // 對輸出的純定義步驟沿著 x 方向的 vector 做計算
            for (int x_vec = 0; x_vec < (input.width() + 15)/16; x_vec++) {
                int x_base = std::min(x_vec * 16, input.width() - 16);

                // 配置 minimum 與 maximum 輔助函數所需的儲存空間
                // 單一 vector 大小已足夠.
                __m128i minimum_storage, maximum_storage;

                // The pure step for the maximum is a vector of zeros
                // maximum 的純定義步驟是數值為 0 的 vector
                maximum_storage = _mm_setzero_si128();

                // maximum 的更新步驟
                for (int max_y = y - 2; max_y <= y + 2; max_y++) {
                    uint8_t *clamped_row =
                        clamped_storage + (max_y & 7) * clamped_width;
                    for (int max_x = x_base - 2; max_x <= x_base + 2; max_x++) {
                        __m128i v = _mm_loadu_si128(
                            (__m128i const *)(clamped_row + max_x + 2));
                        maximum_storage = _mm_max_epu8(maximum_storage, v);
                    }
                }

                // minimum 的純定義步驟是數值為 1 的 vector .
                // 藉由與自身相較來建立
                minimum_storage = _mm_cmpeq_epi32(_mm_setzero_si128(),
                                                  _mm_setzero_si128());

                // minimum 的更新步驟
                for (int min_y = y - 2; min_y <= y + 2; min_y++) {
                    uint8_t *clamped_row =
                        clamped_storage + (min_y & 7) * clamped_width;
                    for (int min_x = x_base - 2; min_x <= x_base + 2; min_x++) {
                        __m128i v = _mm_loadu_si128(
                            (__m128i const *)(clamped_row + min_x + 2));
                        minimum_storage = _mm_min_epu8(minimum_storage, v);
                    }
                }

                // 計算擴散
                __m128i spread = _mm_sub_epi8(maximum_storage, minimum_storage);

                // 寫入
                _mm_storeu_si128((__m128i *)(output_row + x_base), spread);

            }
        }

        free(clamped_storage);
    }
}

// Skip the timing comparison if we don't have openmp
// enabled. Otherwise it's unfair to C.
// 若無使用 OpenMP 則忽略比較時間, 否則對 C 不公平
#ifdef _OPENMP
double t2 = current_time();

// 執行 Halide 版本並避免 JIT 編譯的 overhead.
// 此外也執行 100 次
for (int iters = 0; iters < 100; iters++) {
    spread.realize(halide_result);
}

double t3 = current_time();

// 印出時間的結果, 在作者的機器兩者對於 4-megapixel 的輸入都需要大約 3ms
// 這是合理的, 因為使用相同的 vectorization 與 parallelization 策略
// 然而 Halide 叫容易閱讀, 撰寫, 除錯, 修改與移植
printf("Halide spread took %f ms. C equivalent took %f ms\n",
       (t3 - t2)/100, (t2 - t1)/100);

#endif // _OPENMP

// 確認結果一致:
for (int y = 0; y < input.height(); y++) {
    for (int x = 0; x < input.width(); x++) {
        if (halide_result(x, y) != c_result(x, y)) {
            printf("halide_result(%d, %d) = %d instead of %d\n",
                   x, y, halide_result(x, y), c_result(x, y));
            return -1;
        }
    }
}

#endif // __SSE2__

}

2018年2月14日 星期三

Halide Tutorial 非官方中文翻譯 - Part 3

==

Lesson 6 - 實現任意區域的 Func

// 上一節的內容相當多, 而後續緊接著複雜的 multi-stage pipeline
// 這節是個插曲, 考量一些簡單的事: 如何在非原點開始的長方形區域做計算

// 定義熟悉的 gradient 函數
Func gradient("gradient");
Var x("x"), y("y");
gradient(x, y) = x + y;

// 打開 tracing 功能, 藉此觀察計算的過程
gradient.trace_stores();

// 先前我們以這樣的方式來實現此 gradient 函數
//
// gradient.realize(8, 8);
//
// 內部做了三件事
// 1) 產生能夠在任何長方形計算 gradient 的程式碼
// 2) 配置新的 8x8 影像
// 3) 執行產生的程式碼來計算所有 x, y 的 gradient
// 從 (0, 0) 到 (7, 7) 並且將結果存入該影像
// 4) 返回新影像作為 realize 呼叫的結果

// 然而當小心地管理記憶體, 且不藉由 Halide 配置新的影像的結果是?
// 使用上能夠以另外的方式呼叫 realize - 能夠傳入想要被填入資料的影像
// 下列程式碼能使用 Func 計算結果並且存入早已配置的影像
printf("Evaluating gradient from (0, 0) to (7, 7)\n");
Buffer result(8, 8);
gradient.realize(result);
// Click to show output ...

// Let's check it did what we expect:
for (int y = 0; y < 8; y++) {
    for (int x = 0; x < 8; x++) {
        if (result(x, y) != x + y) {
            printf("Something went wrong!\n");
            return -1;
        }
    }
}

// 接著在其他位置開始的 5x7 長方形計算中 gradient -- 在 (100, 50) 的位置
// 因此 x, y 涵蓋的範圍將由 (100, 50) 到 (104, 56)

// 首先建立一個代表該長方形的影像
Buffer<int> shifted(5, 7); // 在建構子中告知影像大小
shifted.set_min(100, 50); // 接著告知左上角的起始座標

printf("Evaluating gradient from (100, 50) to (104, 56)\n");

// 請注意並不需要重新編譯新的程式碼,
// 因為已經在第一次呼叫 realize 時產生能計算任意長方形 gradient 的程式碼
gradient.realize(shifted);
// Click to show output ...

//  C++ 版本中, 一樣將自 (100, 50) 座標開始存取影像物件
for (int y = 50; y < 57; y++) {
    for (int x = 100; x < 105; x++) {
        if (shifted(x, y) != x + y) {
            printf("Something went wrong!\n");
            return -1;
        }
    }
}
// 'shifted' 影像存放著 Func 自位置 (100, 50) 開始計算的結果,
//  所以要求 shifted(0, 0) 將會產生 out-of-bound 的錯誤, 甚至可能 crash

// 當我們想要以 Func 計算非長方形區域會如何?
// 糟糕, Halide 只能處理長方形 :)

==

Lesson 7 - multi-stage pipeline


// 首先宣告會使用到的變數
Var x("x"), y("y"), c("c");

// 接著會定義用以做模糊影像處理的 multi-stage pipeline
// 流程上先水平後垂直的次序做處理
{
// 輸入為 8-bit 彩色影像
Buffer<uint8_t> input = load_image("images/rgb.png");

// 將精準度展延到 16-bit, 以避免數理操作上的 overflow
Func input_16("input_16");
input_16(x, y, c) = cast<uint16_t>(input(x, y, c));

// 水平模糊:
Func blur_x("blur_x");
blur_x(x, y, c) = (input_16(x-1, y, c) +
                   2 * input_16(x, y, c) +
                   input_16(x+1, y, c)) / 4;

// 垂直模糊:
Func blur_y("blur_y");
blur_y(x, y, c) = (blur_x(x, y-1, c) +
                   2 * blur_x(x, y, c) +
                   blur_x(x, y+1, c)) / 4;

// 轉換回 8-bit 精準度
Func output("output");
output(x, y, c) = cast<uint8_t>(blur_y(x, y, c));

// 每個在這 pipeline 中的 Func 會使用類似的函數呼叫語法來呼叫前一級的 Func
// ( Func 物件的 operator() 已 overloaded)
// 一個 Func 可能會呼叫其他已經給予定義的 Func. (champ:重點在於已定義)
// 這限制避免 pipeline 陷入無限迴圈 (champ:也代表著沒有遞迴)
// Halide 的 pipeline 總是個由多個 Func 所組成單向向前的 Graph (champ:也就是 DAG)

// 接著實現它

// Buffer<uint8_t> result = output.realize(input.width(), input.height(), 3);

// 上面這行並沒有動作, 嘗試將註解移除看看會發生什麼

// 由於 blur_x 會在水平方向上超出範圍而 blur_y 會在垂直方向上超出範圍,
// 因此在與輸入影像相同的起始與範圍實現這個 pipeline 的同時
// 將造成輸入影像必須讀入超過範圍的輸入的情形
// Halide 會在開頭注入一段程式碼偵測這問題, 是先計算影響處理所需讀入的輸入
// 在 pipeline 運作之前會先執行這段程式碼, 確認所需輸入是否超出範圍, 若是則停止
// 在內層 loop 並沒有做確認, 因為會造成效能低落
//
// 那該如何處理這問題? 有幾個選擇. 若上下左右都向內縮減一個 pixel,
// 如此就不會遇到要求 Halide 讀取出界. 而這件事在上一節有展示如何達到
Buffer<uint8_t> result(input.width()-2, input.height()-2, 3);
result.set_min(1, 1);
output.realize(result);

// 將結果存下, 可以看到些微模糊的鸚鵡, 並且影像長寬都比輸入影像少2 pixels
save_image(result, "blurry_parrot_1.png");

// 這通常是最快的處理邊界的方法, 避免寫出超出範圍的程式
// 更通常的作法在下一個例子
}

// 有著輸入邊界條件的相同 pipeline
{
// 輸入為 8-bit 彩色影像
Buffer<uint8_t> input = load_image("images/rgb.png");

// 這次將輸入以另一個避免讀出界的 Func 包起來
Func clamped("clamped");

// 定義一個將 x 限制於 [0, input.width()-1] 範圍的表示式
Expr clamped_x = clamp(x, 0, input.width()-1);
// clamp(x, a, b) 等義於 max(min(x, b), a).

// 同樣地 clamp y
Expr clamped_y = clamp(y, 0, input.height()-1);
// 藉由限制後的座標來讀取輸入. 這表示無論如何計算, 都不會超出輸入的範圍
// 這是 clamp-to-edge 邊界方式, 為在 Halide 中表示上最簡單的邊界處理
clamped(x, y, c) = input(clamped_x, clamped_y, c);

// 藉由使用自 BoundaryConditions 的輔助函式
// 可以更簡潔地定義 'clamped', 像是:
//
// clamped = BoundaryConditions::repeat_edge(input);
//
// 這對於其他邊界條件是重要的 因為這表示方式 Halide 能夠充份理解與優化
// 若使用得當, 這樣的邊界條件就會如同沒有使用一般的高效率

// 展延到 16-bit 精準度, 如此可以避免數理操作的 overflow.
// 這次我們使用新的 Func 'clampled' 取代直接使用輸入影像
Func input_16("input_16");
input_16(x, y, c) = cast<uint16_t>(clamped(x, y, c));

// 剩下的部份與先前一致
// 水平模糊
Func blur_x("blur_x");
blur_x(x, y, c) = (input_16(x-1, y, c) +
                   2 * input_16(x, y, c) +
                   input_16(x+1, y, c)) / 4;

// 垂直模糊
Func blur_y("blur_y");
blur_y(x, y, c) = (blur_x(x, y-1, c) +
                   2 * blur_x(x, y, c) +
                   blur_x(x, y+1, c)) / 4;

// 轉回 8-bit
Func output("output");
output(x, y, c) = cast<uint8_t>(blur_y(x, y, c));

// 由於有做邊界處理, 這次計算如同 input 的起始與範圍是安全的
Buffer <uint8_t>result = output.realize(input.width(), input.height(), 3);

// 儲存結果, 看起來應是些微模糊的鸚鵡
// 但這次大小與輸入一致
save_image(result, "blurry_parrot_2.png");
} 
==

Lesson 8 - Multi-stage pipeline 的排程

// 首先定義後續會用到的變數
Var x("x"), y("y");

// 接著來對一個簡單 two-stage pipeline 測試不同的排程選項
// 首先是預設排程:
{
Func producer("producer_default"), consumer("consumer_default");

// 第一個 stage 是類似於我們熟悉的 gradient 函數的簡單逐點數學運算
// 在座標 (x, y) 的數值是 x 與 y 相乘後代入 sin 函數
producer(x, y) = sin(x * y);

// 接著增加第二個 stage,  動作是將多個來自第一個 stage 的點做數值平均
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;

// 對於這兩個 Func 都打開 tracing 功能
consumer.trace_stores();
producer.trace_stores();

// 接著在 4x4 方塊上計算
printf("\nEvaluating producer-consumer pipeline with default schedule\n");
consumer.realize(4, 4);
// Click to show output ...

// 並沒有對於 producer 計算數值的訊息印出
// 這是因為 producer 完全以 inline 方式於 consumer 中
// 就如同以下的撰寫方式:

// consumer(x, y) = (sin(x * y) +
//                   sin(x * (y + 1)) +
//                   sin((x + 1) * y) +
//                   sin((x + 1) * (y + 1))/4);

// 所以有 producer 的呼叫都被置換為 producer 的內容, 並以變數替換相關參數

// 等義的 C code:
float result[4][4];
for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        result[y][x] = (sin(x*y) +
                        sin(x*(y+1)) +
                        sin((x+1)*y) +
                        sin((x+1)*(y+1)))/4;
    }
}
printf("\n");

// 若檢查巢狀的 loop 會發現 producer 全然沒出現.
// 它已經被 inline 置入 consumer 中
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
printf("\n");
// Click to show output ...
}

// 接著測試下一個簡單的項目 - 在計算任何的 consumer 數值前,
// 事先計算所有在 producer 被需要的數值.
// 這樣的排程稱為 "root"
{
// 首先使用相同的定義
Func producer("producer_root"), consumer("consumer_root");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;

// 告知 Halide 在計算 consumer 之前, 要計算所有的 producer 數值
producer.compute_root();

// 打開 tracing 功能
consumer.trace_stores();
producer.trace_stores();

// 編譯與執行
printf("\nEvaluating producer.compute_root()\n");
consumer.realize(4, 4);
// Click to show output ...

// 從輸出可以看出:
// A) 有來自 producer 的寫入
// B) 這些全發生在任何的 consumer 寫入之前

// 以下為視覺化結果
// producer 在左, consumer 在右
// 寫入標示為橘色, 讀取標示為藍色

// 等義的 C code:

float result[4][4];

// 配置一些暫存空間給 producer
float producer_storage[5][5];

// Compute the producer.
for (int y = 0; y < 5; y++) {
    for (int x = 0; x < 5; x++) {
        producer_storage[y][x] = sin(x * y);
    }
}

// 計算 consumer 的部份, 這次忽略印出的訊息
for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        result[y][x] = (producer_storage[y][x] +
                        producer_storage[y+1][x] +
                        producer_storage[y][x+1] +
                        producer_storage[y+1][x+1])/4;
    }
}

// 注意 consumer 是計算 4x4 正方, 因此 Halide 將自動推斷 producer 需要 5x5 正方資料
// 這與前一節的邊界推斷的邏輯相同, 主要用來偵測與避免自輸入影像作超出邊界讀取

// 若印出 loop 的巢狀, 會看到很類似於上面的 C code
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
printf("\n");
// Click to show output ...
}

// 接著比較上面兩種方法的效率

// Full inlining (預設的排程):
// - 暫時的記憶體配置: 0
// - 讀取: 0
// - 寫入: 16
// - sin呼叫次數: 64

// producer.compute_root():
// - 暫時的記憶體配置: 25 floats
// - 讀取: 64
// - 寫入: 41
// - sin呼叫次數: 25

// 這是一些要考量的妥協, inline 使用了最少的暫時記憶體與記憶體頻寬
// 但使用了大量昂貴數學計算. 在 producer 中大部份的點被計算了4次
// 而第二種排程 producer.compute_root() 有著最少次的 sin 呼叫,
// 但使用較多的暫時記憶體與記憶體頻寬
//
// 任何情況下都很難做出正確的選擇. 若是記憶體頻寬限制或是沒有太多記憶體,
// (例如: 可能在舊手機上執行), 那麼做重複的數學計算有其意義. 
// 反過來說 sin 呼叫是昂貴的, 若是計算資源受限, 較少使用 sin 讓程式更快
// 對程式 vectorize 或是多核平行將偏好做重複的計算, 因為多核增加了計算
// 吞吐量, 而不會增加系統頻寬或是容量

// 如此能夠在 full inlining 與 compute_root 中做選擇.
// 接著測試在以每條 scanline 為基礎的方式在 producer 與 consumer 間計算
{
// 首先使用相同的定義
Func producer("producer_y"), consumer("consumer_y");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;

// 告知 Halide 在 consumer 的每個 y 座標計算所需的 producer 數值
producer.compute_at(consumer, y);

// equivalent C below.
// 這部分的計算 producer 的 code 就是在 consumer 的 y  loop "之中"
// 如同下列等義的 C code.

// 打開 tracing 功能
producer.trace_stores();
consumer.trace_stores();

// 編譯與執行
printf("\nEvaluating producer.compute_at(consumer, y)\n");
consumer.realize(4, 4);
// Click to show output ...

// 下圖為視覺化結果


// 閱讀 log 與查看上圖, 就會了解 producer 與 consumer 間基於每條 scanline 的方式
// 以下為等義的 C code:

float result[4][4];

// 有一個外部的 loop 來掃過 consumer 的 scanline
for (int y = 0; y < 4; y++) {

    // 配置空間與計算足夠的 producer 來滿足單一 scanline consumer 的計算
    // 這表示一個 5x2 的 producer 長方形
    float producer_storage[2][5];
    for (int py = y; py < y + 2; py++) {
        for (int px = 0; px < 5; px++) {
            producer_storage[py-y][px] = sin(px * py);
        }
    }

    // 計算單條 consumer 的 scanline
    for (int x = 0; x < 4; x++) {
        result[y][x] = (producer_storage[0][x] +
                        producer_storage[1][x] +
                        producer_storage[0][x+1] +
                        producer_storage[1][x+1])/4;
    }
}

// 同樣地, 若印出迴圈巢狀結構, 將看到類似上面的 C code.
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
printf("\n");
// Click to show output ...

// 這樣策略在效能上的特性是介於 inline 與 compute root.
// 依然需要配置暫時記憶體, 但少於 compute root 且有著較佳的 locailty
// (在寫入後會需要很快的載入, 對於較大的影像來說, 數值應還在 cache)
// 依然是需要重複的計算, 但少於 full inlining.

// producer.compute_at(consumer, y):
// - 暫時記憶體: 10 floats
// - 讀取: 64
// - 寫入: 56
// - sin呼叫次數: 40
}

// 這裡可以繼續研究 producer.compute_at(consumer, x),
// 但這與 full inlining (預設排程)非常相似.
// 因此判斷在不同 loop 中配置 producer 的儲存空間
// 以及在不同的 loop 中實際地計算數值, 這將產生一些優化方向
{
Func producer("producer_root_y"), consumer("consumer_root_y");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;


// 告知 Halide 在最外層配置存放所有 producer 的空間
producer.store_root();
// ... 但在每個 consumer 的 y 座標才去作計算
producer.compute_at(consumer, y);

producer.trace_stores();
consumer.trace_stores();

printf("\nEvaluating producer.store_root().compute_at(consumer, y)\n");
consumer.realize(4, 4);
// Click to show output ...

// 下圖為視覺化結果

// 閱讀 log 與察看上圖會得知
// 這也是基於介於 producer 與 consumer 間每條 scanline 的方式
// 先計算 5x2 producer 的長方形以滿足第一條 consumer scanline 的計算
// 在此之後儘需要計算 5x1 的長方形來計算新的 consumer scanline
//
// Halide 偵測到對於所有的 scanline 除了第一條之外,
// 能夠重複使用在配置給 producer buffer 中既有的數值
// 以下為等義 C code:

float result[4][4];

// producer.store_root() 表示空間在此:
float producer_storage[5][5];

// 這是用來掃過 consumer 每條 scanline 的外層 loop
for (int y = 0; y < 4; y++) {

    // 計算足夠的 producer 以滿足這次 consumer scanline 的計算
    for (int py = y; py < y + 2; py++) {

        // 若所需的 row 是先前已經被計算過的 producer 數值, 則跳過
        if (y > 0 && py == y) continue;

        for (int px = 0; px < 5; px++) {
            producer_storage[py][px] = sin(px * py);
        }
    }

    // 計算 consumer scanline
    for (int x = 0; x < 4; x++) {
        result[y][x] = (producer_storage[y][x] +
                        producer_storage[y+1][x] +
                        producer_storage[y][x+1] +
                        producer_storage[y+1][x+1])/4;
    }
}

printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
printf("\n");
// Click to show output ...

// 這個策略的效能特性很好, 數字上接近 compute_root, 除了 locality 更好.
// 使用了最少次數的 sin 呼叫, 並且在寫入數值後快速的被載入
// 因此可能良好地使用 cache.

// producer.store_root().compute_at(consumer, y):
// - 暫時記憶體配置: 10 floats
// - 讀取: 64
// - 寫入: 39
// - sin呼叫次數: 25

// 注意宣稱的配置記憶體數量與 C code 並不一致
// Halide 在此之下有進一步地優化.
// 它將 producer 轉為大小為 2 scanline 的 circular buffer
// 等義的 code 如下:

{
    // 實際上寫入 2 條 scanline 而非 5 條
    float producer_storage[2][5];
    for (int y = 0; y < 4; y++) {
        for (int py = y; py < y + 2; py++) {
            if (y > 0 && py == y) continue;
            for (int px = 0; px < 5; px++) {
                // 透過  y 座標 bit-masked 後存入 producer_storage
                producer_storage[py & 1][px] = sin(px * py);
            }
        }

        // 計算 consumer scanline
        for (int x = 0; x < 4; x++) {
            // 透過 y 座標 bit-masked 後, 自 producer_storage 載入資料
            result[y][x] = (producer_storage[y & 1][x] +
                            producer_storage[(y+1) & 1][x] +
                            producer_storage[y & 1][x+1] +
                            producer_storage[(y+1) & 1][x+1])/4;
        }
    }
}
}

// 藉由儲存空間放置最外層並將計算移到最內層, 還能做到更好的結果
{
Func producer("producer_root_x"), consumer("consumer_root_x");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;

// 存放在最外層, 計算在最內層
producer.store_root().compute_at(consumer, x);

producer.trace_stores();
consumer.trace_stores();

printf("\nEvaluating producer.store_root().compute_at(consumer, x)\n");
consumer.realize(4, 4);
// Click to show output ...

// 下圖為視覺化結果


// consumer 與 producer 間的計算是基於每個點的方式
// 以下為等義 C code:

float result[4][4];

// producer.store_root() 表示存放空間在此
// 能夠將其以 circular buffer 方式減少為 2 scanlines
float producer_storage[2][5];

// 對於每個 consumer 的 pixel
for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {

        // 計算足夠的 producer 以滿足這次 consumer pixel 的計算
        // 但必須忽略已經計算過的數值:
        if (y == 0 && x == 0)
            producer_storage[y & 1][x] = sin(x*y);
        if (y == 0)
            producer_storage[y & 1][x+1] = sin((x+1)*y);
        if (x == 0)
            producer_storage[(y+1) & 1][x] = sin(x*(y+1));
        producer_storage[(y+1) & 1][x+1] = sin((x+1)*(y+1));

        result[y][x] = (producer_storage[y & 1][x] +
                        producer_storage[(y+1) & 1][x] +
                        producer_storage[y & 1][x+1] +
                        producer_storage[(y+1) & 1][x+1])/4;
    }
}

printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
printf("\n");
// Click to show output ...

// 效能上的特性是至今最好的.
// 四分之一所需的 producer 數值依然在暫存器中, 因此不計入讀取數
// producer.store_root().compute_at(consumer, x):
// - 配置的暫時記憶體: 10 floats
// - 讀取: 48
// - 寫入: 56
// - sin呼叫次數: 40
}

// 因此得知了什麼?
// 為何針對此類型不總是 producer.store_root().compute_at(consumer, x)?
//
// 答案在於 parallelism. 在先前兩個策略中, 已經假設先前已計算的數值等待著被使用
// 這假設了時間上在先前發生的 x 或 y 已經結束. 對於已平行化或向量化的 loop 來說,
// 並不是正確的. 若平行化處理後, 若在 store_at 與 compute_at 之間有著平行 loop
// Halide 不會注入跳過計算的優化. 也不會將儲存空間轉為 circular buffer, 這使得
// store_root 失去意義

// 目前已經試過了所有選項. 還能做的新方式是藉由 splitting.
// 能夠 store_at 或 compute_at 在 consumer 的變數 loop 上
// 或是 split x or y 轉為新的內層與外層的子變數然後對此排程
// 這裡合併使用這方式為 tiles
{
Func producer("producer_tile"), consumer("consumer_tile");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;

// 以 4x4 tile 的方式來計算 8x8 consumer
Var x_outer, y_outer, x_inner, y_inner;
consumer.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4);

// 計算每個 tile 所需的 producer
producer.compute_at(consumer, x_outer);

// 注意排程是以終端開始撰寫. 這是因為對於 producer 排程需要使用 x_outer
// 這變數是在將 consumer 分為 tile 的時候才導入.
// 是能夠以其他的次序撰寫, 但程式碼將難以閱讀

// 打開 tracing 功能
producer.trace_stores();
consumer.trace_stores();

printf("\nEvaluating:\n"
       "consumer.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4);\n"
       "producer.compute_at(consumer, x_outer);\n");
consumer.realize(8, 8);
// Click to show output ...

// 以下為視覺化結果

// producer 與 consumer 間的計算現在是基於每個 tile
// 以下為等義的 C code:

float result[8][8];

// 對於每個 consumer tile
for (int y_outer = 0; y_outer < 2; y_outer++) {
    for (int x_outer = 0; x_outer < 2; x_outer++) {
        // 計算在此 tile 的 x, y 起始座標
        int x_base = x_outer*4;
        int y_base = y_outer*4;

        // 計算足夠的 producer 以滿足此 consumer tile 的計算
        // 一個 4x4 consumer tile 需要一個 5x5 producer tile
        float producer_storage[5][5];
        for (int py = y_base; py < y_base + 5; py++) {
            for (int px = x_base; px < x_base + 5; px++) {
                producer_storage[py-y_base][px-x_base] = sin(px * py);
            }
        }

        // 計算此 consumer tile
        for (int y_inner = 0; y_inner < 4; y_inner++) {
            for (int x_inner = 0; x_inner < 4; x_inner++) {
                int x = x_base + x_inner;
                int y = y_base + y_inner;
                result[y][x] =
                    (producer_storage[y - y_base][x - x_base] +
                     producer_storage[y - y_base + 1][x - x_base] +
                     producer_storage[y - y_base][x - x_base + 1] +
                     producer_storage[y - y_base + 1][x - x_base + 1])/4;
            }
        }
    }
}

printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
printf("\n");
// Click to show output ...

// 對於會觸及到 x, y 外部的模板問題, 使用 tiling 有其意義.
// 每個 tile 能平行地被獨立計算.
// 而當 tile 夠大時需要重複的工作就不是什麼大問題
}

// 最後嘗試合併先前做過的 splitting, parallelizing 與 vectoring 的混合策略
// 這方式對於實際上的大型影像運作的不錯
// 若了解這個排程, 就已經掌握了 Halide 中 95% 的排程
{
Func producer("producer_mixed"), consumer("consumer_mixed");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;

// 將 y 座標以 16-scanline strip 的方式分割
Var yo, yi;
consumer.split(y, yo, yi, 16);
// 使用 thread pool 與 task queeu 來計算這些 strips
consumer.parallel(yo);
// 在 x 方向上作 vectorization
consumer.vectorize(x, 4);

// 將 per-strip producer 資訊存放起來. 這會是 17 條 producer scanlines
// 但希望能折為大小為 2 條 scanline 的 circular buffer
producer.store_at(consumer, yo);
/ 在每個 strip 中計算 consumer 所需的每條 producer scanline,
// 並忽略先前已計算過的 scanline
producer.compute_at(consumer, yi);
// 並且對 producer 作 vectorization (由於在 x86 SSE,  sin 是能夠 vectorized 的)
producer.vectorize(x, 4);

// 這次關閉 tracing, 因為需要計算大型影像
// consumer.trace_stores();
// producer.trace_stores();

Buffer halide_result = consumer.realize(160, 160);

// 以下為視覺化結果

// 以下為等義 C code:

float c_result[160][160];

// 對於每個 16-scanline strip (在 Halide 中這層 loop 是平行的)
for (int yo = 0; yo < 160/16 + 1; yo++) {

    // 16 無法分割 160, 因此將最後的部分上推
    // 讓範圍符合 [0, 159] (見 lesson 05).
    int y_base = yo * 16;
    if (y_base > 160-16) y_base = 160-16;

    // 針對 producer 配置 2-scanline 大小的 circular buffer
    float producer_storage[2][161];

    // 對於 strip 中 16 條中的每一條 scanline:
    for (int yi = 0; yi < 16; yi++) {
        int y = y_base + yi;

        for (int py = y; py < y+2; py++) {
            // 略過在這次工作中已被計算的 scanline
            if (yi > 0 && py == y) continue;

            // 以 4-wide vector 方式計算這條 producer scanline
            for (int x_vec = 0; x_vec < 160/4 + 1; x_vec++) {
                int x_base = x_vec*4;
                // 4 無法分割 161, 因此將最後的 vector 向左推
                // (見 lesson 05).
                if (x_base > 161 - 4) x_base = 161 - 4;
                // 若在 x86 平台上, Halide 會針對這部分產生 SSE 程式碼:
                int x[] = {x_base, x_base + 1, x_base + 2, x_base + 3};
                float vec[4] = {sinf(x[0] * py), sinf(x[1] * py),
                                sinf(x[2] * py), sinf(x[3] * py)};
                producer_storage[py & 1][x[0]] = vec[0];
                producer_storage[py & 1][x[1]] = vec[1];
                producer_storage[py & 1][x[2]] = vec[2];
                producer_storage[py & 1][x[3]] = vec[3];
            }
        }

        // 計算這次的 consumer scanline:
        for (int x_vec = 0; x_vec < 160/4; x_vec++) {
            int x_base = x_vec * 4;
            // Again, Halide's equivalent here uses SSE.
            int x[] = {x_base, x_base + 1, x_base + 2, x_base + 3};
            float vec[] = {
                (producer_storage[y & 1][x[0]] +
                 producer_storage[(y+1) & 1][x[0]] +
                 producer_storage[y & 1][x[0]+1] +
                 producer_storage[(y+1) & 1][x[0]+1])/4,
                (producer_storage[y & 1][x[1]] +
                 producer_storage[(y+1) & 1][x[1]] +
                 producer_storage[y & 1][x[1]+1] +
                 producer_storage[(y+1) & 1][x[1]+1])/4,
                (producer_storage[y & 1][x[2]] +
                 producer_storage[(y+1) & 1][x[2]] +
                 producer_storage[y & 1][x[2]+1] +
                 producer_storage[(y+1) & 1][x[2]+1])/4,
                (producer_storage[y & 1][x[3]] +
                 producer_storage[(y+1) & 1][x[3]] +
                 producer_storage[y & 1][x[3]+1] +
                 producer_storage[(y+1) & 1][x[3]+1])/4};

            c_result[y][x[0]] = vec[0];
            c_result[y][x[1]] = vec[1];
            c_result[y][x[2]] = vec[2];
            c_result[y][x[3]] = vec[3];
        }

    }
}
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
printf("\n");
// Click to show output ...

// 看著程式碼, 讚嘆與絕望

// 檢測 C 與 Halide 的結果
// 藉由這反而發現了 C 實作過程的許多錯誤
// 這應該也能學習到一些
for (int y = 0; y < 160; y++) {
    for (int x = 0; x < 160; x++) {
        float error = halide_result(x, y) - c_result[y][x];
        // It's floating-point math, so we'll allow some slop:
        if (error < -0.001f || error > 0.001f) {
            printf("halide_result(%d, %d) = %f instead of %f\n",
                   x, y, halide_result(x, y), c_result[y][x]);
            return -1;
        }
    }
}

}

// 這部分是困難的. 最終是在記憶體頻寬, 重複計算與平行化這三個方向之間的考量
// Halide 無法自動做出正確的決定.
// 相對地它讓探索不同的選項變得簡單, 無需弄亂程式碼.
// 事實上 Halide 保證像是 compute_root 的排程呼叫並不會改變演算本身的意義
// -- 無論如何地排程, 總是會得到相同的結果

// 所以讓自己身經百戰吧 !
// 持續實驗不同的排程並且對效能做紀錄.
// 形成假設並嘗試證明自己錯了
// 不能假設只要以 4-wide vectorization 並且在 8 cores 上執行,
// 以為如此就能得到 32倍的速度, 這樣是不正確的.
// 現代系統複雜到無法不執行程式碼就能可靠正確評估效能

// 建議一開始將所有 stage 以 compute_root 做排程
// 接著自 pipeline 終端往前逐個 stage 作 inline, parallelizing 與 vectorizing
// 如此直到 pipeline 的頭端

// Halide 並不只是將程式碼 vectorizing 與 parallelizing.
// 光是這些優化不足以讓人們走得更遠.
// Halide 重點在於賦予工具能在無需弄亂嘗試得到的結果的情況下
// 快速探索在局部性, 重複計算與平行化等不同方向的考量
//


2018年2月13日 星期二

Halide Tutorial 非官方中文翻譯 - Part 2

說是 Part 2 也僅有 Lesson 5, 而看了一下後續, 這系列大概會分成 9 ~ 10 個部份
Lesson 5 是相當重要的一節, 內容也相當多, 主要在於它提供了底層排程上的操作
對於排程與硬體計算架構的了解有助於快速撰寫適合的排程描述
可以說 Halide 在撰寫程式上的重點之一即在於此
==

Lesson 5 - Vectorize, parallelize, unroll 與 tile 的使用

// 本節使用多種不同方式定義與排程 gradient 函數,
// 並藉此觀察 pixel 被計算的次序

Var x("x"), y("y");
// 預設次序
{
Func gradient("gradient");
gradient(x, y) = x + y;
gradient.trace_stores();

// 預設來說它是個 raster-scan 的方式, 也就是逐條水平掃過
// 也就是 x 會快速變動, 而 y 變動較為緩慢
// 這是個 row-major 的掃描方式
printf("Evaluating gradient row-major\n");
Buffer<int> output = gradient.realize(4, 4);
// Click to show output ...

// 下圖為視覺化結果


// 等義的 C 程式碼
printf("Equivalent C:\n");
for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
    }
}
printf("\n\n");

// Tracing 是一個有效了解排程如何運作的方式
// 也可以要求 Halide 印出 pseudocode 來顯示 Halide 所產生的迴圈
printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...

// 由於使用預設次序, 輸出會是:
// compute gradient:
//   for y:
//     for x:
//       gradient(...) = ...
}

// reordering: 重新調整變數次序
{
Func gradient("gradient_col_major");
gradient(x, y) = x + y;
gradient.trace_stores();

// 若想要重新調整 x, y 的次序, 即能夠以垂直的方式掃描
// 重新調整次序呼叫使用 Func 的參數, 並且依序設定新的巢狀 for loop 次序
// 參數設定的方式是由最內層 loop 依序向外層設定
// 所以下列呼叫將 y 置於內層 loop
gradient.reorder(y, x);

// 這表示 y 將快速地變動, 而 x 變為變動緩慢
// 是個 column-major 的掃描方式

printf("Evaluating gradient column-major\n");
Buffer output = gradient.realize(4, 4);
// Click to show output ...

// 下圖為視覺化結果


printf("Equivalent C:\n");
for (int x = 0; x < 4; x++) {
    for (int y = 0; y < 4; y++) {
        printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
    }
}
printf("\n");

// 若印出此排程的 pseudocode, 將會看到 y 的 loop 現在置於 x 的 loop 中
printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...
}

// splitting: 將變數一分為二
{
Func gradient("gradient_split");
gradient(x, y) = x + y;
gradient.trace_stores();

// 對於變數而言, 最強大排程操作是能夠將變數分為內部與外部的子變數
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 2);

// 這能夠將 x 的 loop 分為巢狀的兩層 loop:
// 一個外部的 x_outer loop 以及一個內部的 x_inner loop
// split 呼叫所使用到的最後一個參數稱為 split factor.
// 其表示在內層的 loop 將自 0 遞增到 split factor.
// 而外部的 loop 將自 0 遞增到 x (這個例子為 4) 除以 split factor
// 在這個 loop 中舊有參數將被分為 outer*factor + inner 這樣的方式.
// 若舊 loop 是自非 0 值起算, 這個 offset 將會加到這個新 loop 中

printf("Evaluating gradient with x split into x_outer and x_inner \n");
Buffer output = gradient.realize(4, 4);
// Click to show output ...

printf("Equivalent C:\n");
for (int y = 0; y < 4; y++) {
    for (int x_outer = 0; x_outer < 2; x_outer++) {
        for (int x_inner = 0; x_inner < 2; x_inner++) {
            int x = x_outer * 2 + x_inner;
            printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
        }
    }
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...

// 注意計算 pixel 數值的次序實際上並不會改變
// Splitting 只是打開了排程上探尋的可能性
// 後續會做說說明
}

// fusing: 合併兩個變數為一個
{
Func gradient("gradient_fused");
gradient(x, y) = x + y;

// 相對於 splitting 的操作是 'fusing'
// fusing 兩個變數將會合併兩個 loops 為單一 for loop
// fusing 比起 splitting 較為不重要, 但它還是有其用途
// 如同 splitting, fusing 本身實際上並不會改變計算的次序
Var fused;
gradient.fuse(x, y, fused);

printf("Evaluating gradient with x and y fused\n");
Buffer output = gradient.realize(4, 4);

printf("Equivalent C:\n");
for (int fused = 0; fused < 4*4; fused++) {
    int y = fused / 4;
    int x = fused % 4;
    printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...
}

// 以 tile 方式計算
{
Func gradient("gradient_tiled");
gradient(x, y) = x + y;
gradient.trace_stores();

// 現在能夠同時作 split 與 reorder 如此能夠達到 tile 方式計算
// 將 x, y 各以 factor 4 作 split 操作
// 並且 reorder 變數來表示 tile 的掃描方式
//
// Tile 掃描方式將原有區域劃分為多個較小長方形的 tile
// 而外部的 loop 在於掃過每個 tile, 而內部的 loop 則掃描 tile 中的每個點.
// 若鄰近的 pixel 有著重複的輸入資料像是 blur, 這樣的方式對於效能上有好處
// 我們能夠表示 tile 的掃描方式如下:
Var x_outer, x_inner, y_outer, y_inner;
gradient.split(x, x_outer, x_inner, 4);
gradient.split(y, y_outer, y_inner, 4);
gradient.reorder(x_inner, y_inner, x_outer, y_outer);
// 這樣的模式其一般性足以有簡短表示
// gradient.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4);

printf("Evaluating gradient in 4x4 tiles\n");
Buffer output = gradient.realize(8, 8);
// Click to show output ...

// 以下為視覺化結果


printf("Equivalent C:\n");
for (int y_outer = 0; y_outer < 2; y_outer++) {
    for (int x_outer = 0; x_outer < 2; x_outer++) {
        for (int y_inner = 0; y_inner < 4; y_inner++) {
            for (int x_inner = 0; x_inner < 4; x_inner++) {
                int x = x_outer * 4 + x_inner;
                int y = y_outer * 4 + y_inner;
                printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
            }
        }
    }
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...
}

// 以 vector 方式做計算
{
Func gradient("gradient_in_vectors");
gradient(x, y) = x + y;
gradient.trace_stores();

// splitting 的好處是其保證內部的變數自 0 遞增到 split factor.
// 多數情況 split-factor 在編譯時期會是常數,
// 因此我們能夠將內部 loop 使用 vector 做計算
// 這次我們以 factor 4 做 split, 因為在 x86 平台上 SSE 能提供 4-wide vector
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 4);
gradient.vectorize(x_inner);

// Splitting 接著於內部 loop 作 Vectorizing , 其一般性足以有簡短表示:
//
// gradient.vectorize(x, 4);
//
// 即等同於:
//
// gradient.split(x, x, x_inner, 4);
// gradient.vectorize(x_inner);
//
// 注意在這個例子中, 我們重複使用了 'x' 作為新的外部 loop 的變數名稱
// 而緊接著的排程使用到 x 將以此作為新的外部 loop 變數

// 這次將以 8x4 方塊的方式來計算,
// 因此每 scanline 將會有超過一個 vector 的工作
printf("Evaluating gradient with x_inner vectorized \n");
Buffer output = gradient.realize(8, 4);
// Click to show output ...

// 下圖為視覺化結果


printf("Equivalent C:\n");
for (int y = 0; y < 4; y++) {
    for (int x_outer = 0; x_outer < 2; x_outer++) {
        // 在此 x_inter loop 被 vectorized 版本取代而消失了
        // 於 x86 處理器上, Halide 對這些產生 SSE 程式碼

        int x_vec[] = {x_outer * 4 + 0,
                       x_outer * 4 + 1,
                       x_outer * 4 + 2,
                       x_outer * 4 + 3};
        int val[] = {x_vec[0] + y,
                     x_vec[1] + y,
                     x_vec[2] + y,
                     x_vec[3] + y};
        printf("Evaluating at <%d, %d, %d, %d>, <%d, %d, %d, %d>:"
               " <%d, %d, %d, %d>\n",
               x_vec[0], x_vec[1], x_vec[2], x_vec[3],
               y, y, y, y,
               val[0], val[1], val[2], val[3]);
    }
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...
}

// Unrolling: 展開 loop
{
Func gradient("gradient_unroll");
gradient(x, y) = x + y;
gradient.trace_stores();

// 若多個 pixel 分享重複的資料, 對於計算作 unroll loop 就有意義
// 因分享的資料只需計算或載入一次
// 這方式類似於 vectorizing 的方式:
// split 一個維度, 然後 unroll 內部 loop
// 同樣地, Unrolling 並不會改變計算的次序
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 2);
gradient.unroll(x_inner);

// 可簡短表示為:// gradient.unroll(x, 2);

printf("Evaluating gradient unrolled by a factor of two\n");
Buffer result = gradient.realize(4, 4);
// Click to show output ...

printf("Equivalent C:\n");
for (int y = 0; y < 4; y++) {
    for (int x_outer = 0; x_outer < 2; x_outer++) {
        // 對於 x_inner loop 取而代之的是得到兩個類似的 x_inner block
        {
            int x_inner = 0;
            int x = x_outer * 2 + x_inner;
            printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
        }
        {
            int x_inner = 1;
            int x = x_outer * 2 + x_inner;
            printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
        }
    }
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...
}

// 以無法整除的 split factor 作 splitting
{
Func gradient("gradient_split_7x2");
gradient(x, y) = x + y;
gradient.trace_stores();
// splitting 保證內部 loop 自 0 起算到 split factor, 從先前可知這點對應用很重要
// 然而若要處理的內容無法被 split factor 整除時會如何?
// 這裡以 factor 3 方式作 splitting 一個 7x2 的範圍而非先前的 4x4 方塊
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 3);

printf("Evaluating gradient over a 7x2 box with x split by three \n");
Buffer output = gradient.realize(7, 2);
// Click to show output ...

// 以下為視覺化結果
// 請注意有的 pixel 被計算超過一次


printf("Equivalent C:\n");
for (int y = 0; y < 2; y++) {
    for (int x_outer = 0; x_outer < 3; x_outer++) { // Now runs from 0 to 2
        for (int x_inner = 0; x_inner < 3; x_inner++) {
            int x = x_outer * 3;
            // Before we add x_inner, make sure we don't
            // evaluate points outside of the 7x2 box. We'll
            // clamp x to be at most 4 (7 minus the split
            // factor).
            if (x > 4) x = 4;
            x += x_inner;
            printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
        }
    }
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...

// 若察看輸出會發現一些座標點被計算超過一次
// 通常是沒問題的, 因為純 Halide 函數並沒有 side-effects,
// 因此單一點被計算多次是安全的
// 若是使用 C function 則必須確認能處理單一點被重複計算多次的情形

// 通則是: 若 x 自 x_min 到 x_min + x_extent , 且需要以 factor 作 splitting 則:
//
// x_outer loop 自 0 增加到 (x_extent + factor - 1)/factor
// x_inner loop 自 0 增加到 factor
// x = min(x_outer * factor, x_extent - factor) + x_inner + x_min
//
// 在範例中 x_min 為 0 , x_extent 為 7 而 factor 為 3

// 然而當使用一個更新定義撰寫 Halide 函數時(lesson 9),
// 單一點的重複計算並不是安全的, 因此不要使用這技巧
// 相對地計算範圍將會調整到下一個 split factor 的倍數
}

// Fusing, Tiling 與 Parallelizing
{
// 在先前得知能夠在一個變數上做平行化
// 這裡將它與 fusing 及 tiling 合併應用產生有用的模式 - 平行處理 tiles

// 這部份是 fusing 好用的地方.
// fusing 讓跨多維度的平行無需使用巢狀平行化.
// Halide 支援巢狀平行化(在平行化的 for loop 之中作 for loop的平行化)
// 相較於 fusing 多個變數來做單一 for loop 平行化, 通常巢狀平行化帶來較差的效能

Func gradient("gradient_fused_tiles");
gradient(x, y) = x + y;
gradient.trace_stores();

// 首先分為多個 tile, 並且合併 tile 索引
// 並且對合併後作平行化
Var x_outer, y_outer, x_inner, y_inner, tile_index;
gradient.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4);
gradient.fuse(x_outer, y_outer, tile_index);
gradient.parallel(tile_index);

// 排程的呼叫會傳回 Func 的 reference,
// 因此能夠將其串連起來成為單一敘述, 能相對簡潔
//
// gradient
//     .tile(x, y, x_outer, y_outer, x_inner, y_inner, 2, 2)
//     .fuse(x_outer, y_outer, tile_index)
//     .parallel(tile_index);


printf("Evaluating gradient tiles in parallel\n");
Buffer output = gradient.realize(8, 8);
// Click to show output ...

// 可以觀察到 Tiles 將被亂序地處理, 在每個 tile 中都是以 row-major 做處理
// 以下為視覺化結果


printf("Equivalent (serial) C:\n");
// 最外層的 loop 應該是個平行化的 for loop, 但難以用 C 表示

for (int tile_index = 0; tile_index < 4; tile_index++) {
    int y_outer = tile_index / 2;
    int x_outer = tile_index % 2;
    for (int y_inner = 0; y_inner < 4; y_inner++) {
        for (int x_inner = 0; x_inner < 4; x_inner++) {
            int y = y_outer * 4 + y_inner;
            int x = x_outer * 4 + x_inner;
            printf("Evaluating at x = %d, y = %d: %d\n", x, y, x + y);
        }
    }
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient.print_loop_nest();
printf("\n");
// Click to show output ...
}

// 綜合應用
{
// 接著使用上面所有的功能特性
Func gradient_fast("gradient_fast");
gradient_fast(x, y) = x + y;

// 將影像分為 64x64 tiles 並作平行化
Var x_outer, y_outer, x_inner, y_inner, tile_index;
gradient_fast
    .tile(x, y, x_outer, y_outer, x_inner, y_inner, 64, 64)
    .fuse(x_outer, y_outer, tile_index)
    .parallel(tile_index);

// 當掃描每個 tile 過程中同時計算兩個 scanline
// 最簡單的表示方式是在 tile 中再次 tiling 到 4x2 的 subtile
// 接著在 x 方向與 y 方向做 vectorize 與 subtile :
Var x_inner_outer, y_inner_outer, x_vectors, y_pairs;
gradient_fast
    .tile(x_inner, y_inner, x_inner_outer, y_inner_outer, x_vectors, y_pairs, 4, 2)
    .vectorize(x_vectors)
    .unroll(y_pairs);

// 注意並沒有明確的 split 或 reorder.
// 這些是最為重要的基本操作, 但通常隱藏在 tiling, vectorizing 或是 unrolling 呼叫之下

// 接著計算非 tile 大小整數的範圍

// 若喜歡可以打開 tracing 功能, 但也將印出大量的訊息
// 相對地能夠在 C 與 Halide 計算並確認是否一致
Buffer result = gradient_fast.realize(350, 250);

//以下為視覺化結果

printf("Checking Halide result against equivalent C...\n");
for (int tile_index = 0; tile_index < 6 * 4; tile_index++) {
    int y_outer = tile_index / 4;
    int x_outer = tile_index % 4;
    for (int y_inner_outer = 0; y_inner_outer < 64/2; y_inner_outer++) {
        for (int x_inner_outer = 0; x_inner_outer < 64/4; x_inner_outer++) {
            // 在 x 方向作 vectorize
            int x = std::min(x_outer * 64, 350-64) + x_inner_outer*4;
            int x_vec[4] = {x + 0,
                            x + 1,
                            x + 2,
                            x + 3};

            // 以及在 y 方向 unrolling
            int y_base = std::min(y_outer * 64, 250-64) + y_inner_outer*2;
            {
                // y_pairs = 0
                int y = y_base + 0;
                int y_vec[4] = {y, y, y, y};
                int val[4] = {x_vec[0] + y_vec[0],
                              x_vec[1] + y_vec[1],
                              x_vec[2] + y_vec[2],
                              x_vec[3] + y_vec[3]};

                // Check the result.
                for (int i = 0; i < 4; i++) {
                    if (result(x_vec[i], y_vec[i]) != val[i]) {
                        printf("There was an error at %d %d!\n",
                               x_vec[i], y_vec[i]);
                        return -1;
                    }
                }
            }
            {
                // y_pairs = 1
                int y = y_base + 1;
                int y_vec[4] = {y, y, y, y};
                int val[4] = {x_vec[0] + y_vec[0],
                              x_vec[1] + y_vec[1],
                              x_vec[2] + y_vec[2],
                              x_vec[3] + y_vec[3]};

                // Check the result.
                for (int i = 0; i < 4; i++) {
                    if (result(x_vec[i], y_vec[i]) != val[i]) {
                        printf("There was an error at %d %d!\n",
                               x_vec[i], y_vec[i]);
                        return -1;
                    }
                }
            }
        }
    }
}
printf("\n");

printf("Pseudo-code for the schedule:\n");
gradient_fast.print_loop_nest();
printf("\n");
// Click to show output ...

// 注意在這 Halide 版本中, 演算只有在最上面作一次的指定,
// 接著再個別做不同的優化, 總共並沒有很多行程式碼
// 而相較之下 C 版本有著較多行的程式碼
// 通常惱人的是分散多處對於演算的陳述造成的凌亂
// 而 C 程式碼並不容易撰寫, 且難以閱讀與除錯, 更難以進一步地優化
// 而這也是為什麼 Halide 產生並存在的原因
}

Halide Tutorial 非官方中文翻譯 - Part 1

新的一年希望深入 Halide, 借助於抽象化的力量來提升優化的效率與能力
因次藉由複習與深入, 重新研讀了官方的 Tutorial
並且做了簡短的意譯, 一方面確認自己確實地了解, 一方面建立相關中文教學資源
==

Lesson 01 - 認識 Func, Vars 與 Expr

// Lesson 1 主要在於展示 Halide JIT compiler 對於影像的基本使用
// Halide 的使用僅需 include 的 header 檔為 Halide.h

// Func 物件所代表的是 pipeline stage.
// 用以定義每個 pixel 數值函數, 可被認為是被計算出的影像.
Halide::Func gradient;

// Var 物件主要是作為宣告用以定義一個 Func 的變數.
// 這個物件本身並沒有額外的意義.
Halide::Var x, y;

// 通常我們使用名為 x 與 y 的變數來代表影像中的座標.
// 若你習慣以行列思考方式思考, 那麼 x 即為行的索引, y 為列的索引

// Func 被以一個透過變數與函數所組成的 Expr 來定義, 並以變數表示任意整數座標.
// Var 已被適當地 overloading 所以 x + y 會轉變為 Expr 物件.
Halide::Expr e = x + y;

// 將定義增加到 Func 物件中, 在 x, y 座標的 pixel 將或有著 Expr 所表示的數值.
// 在等號左方我們定應了一些變數; 而右邊有著使用相同變數的 Expr.
gradient(x, y) = e;

// 接著藉由 JIT 編譯實作所定義的 pipeline 程式碼以"實現"這個 Func 並執行這個 pipeline.
// 必須要告知 Halide 用來決定 x, y 範圍的值域與影像的解析度.
// Halide.h 提供了能直接使用的基本的影像 C++ template.
// 在這個範例嘗試產生 800 x 600 的影像
Halide::Buffer<int32_t> output = gradient.realize(800, 600);

// Halide 有著型別推斷功能. Var 物件表示 32-bit 的整數
// 因此 x + y 亦表示著 32-bit 整數, 因此 32-bit gradient 定義的影像
// 當呼叫 realize 我們將得到 32-bit 的整數影像, Halide 的型別轉換方式等同於 C 語言

==

Lesson 02 - 影像處理

// 這節主要展示如何傳入輸入影像與做處理

// 首先載入我們想要調亮的輸入影像
Halide::Buffer<uint8_t> input = load_image("images/rgb.png");

// 接著定義用來表示 pipeline stage 的 Func 物件
Halide::Func brighter;

// 該 Func 將會使用三個參數, 分別表示在影像中的 position 與 color channel.
// Halide 將 color channel 視為影像額外的維度.
Halide::Var x, y, c;

// 通常我們可能將整個函數定義寫成一行. 這裡我們將其分開好能夠逐步解釋

// 對於每個輸入影像的 pixel 
Halide::Expr value = input(x, y, c);

// 型別轉為浮點數
value = Halide::cast
<float>(value);

// 將數值乘上 1.5 來調亮.
// Halide 將實數表示為 float 而非 double,
// 因此我們在常數後方加上了 f.
value = value * 1.5f;

// 將值域限制在小於 255, 如此我們不會在型別轉回 unsigned 8-bit 整數時造成 overflow.
value = Halide::min(value, 255.0f);

// 型別轉換回 unsigned 8-bit 整數
value = Halide::cast
<uint8_t>(value);

// 定義函數
brighter(x, y, c) = value;

// 等同於上述步驟的一行表示方式
// brighter(x, y, c) = Halide::cast
<uint8_t>(min(input(x, y, c) * 1.5f, 255));
// 於簡短版本:
// - 忽略了型別轉為 float, 因為乘上 1.5f 會自動轉換
// - 使用了整數常數作為第2參數, 因為轉為 float 時與第1參數相容
// - 呼叫 min 時省去不必要的 Halide::

// 記住至今所做的是在記憶體中建立 Halide 程式的表示.
// 程式還尚未處理任何的 pixel.
// 甚至還沒有編譯這個 Halide 程式

// 因此這裡實現這個 Func. 輸出影像的大小必須與輸入影像一致.
// 若只想要調亮一部份的輸入影像, 能夠只要求一個較小的大小範圍.
// 然而要求一個較大的大小 Halide 將會在執行時期丟出錯誤,
// 以此告知讀取超出輸入影像大小的範圍.
Halide::Buffer
<uint8_t> output = brighter.realize(input.width(), input.height(), input.channels());

// 將輸出存下來做檢查
save_image(output, "brighter.png");


==

Lesson 03 - 檢查所產生的程式

// 本節展示如何檢查 Halide compiler 所產生的程式碼

// 首先使用來自第一節使用的 pipeline 定義

// 這節是關於 debugging, 但不幸的是在 C++ 中物件並不知道它們自己的名字
// 因此對於我們而言將難以獨懂所產生的程式碼. 為了排除這樣的問題,
// 可以傳遞一個字串提供給 Func 與 Var 的建構子以利除錯
Func gradient("gradient");
Var x("x"), y("y");
gradient(x, y) = x + y;

// 實現這個函數來產生輸出影像. 這節我們只使用非常小的大小
Buffer output = gradient.realize(8, 8);

// 在這節中嘗試設定 HL_DEBUG_CODEGEN 環境變數為 1,
// 它將會印出不同 stage 的編譯結果與一份最後表示 pipeline 的 pseudocode

// 若將 HL_DEBUG_CODEGEN 設為愈大的數值, 則可以看到愈多 Halide 編譯的細節.
// 將 HL_DEBUG_CODEGEN 設為 2 將會顯示在每個 stage 的編譯顯示 Halide code
// 以及最後所產生的 llvm bitcode.

// Halide 也能產生支援標示語法與程式碼折疊的 HTML 版本的輸出.
// 這對於閱讀較大的 pipeline 比較方便.
// 對於這節能在執行範例後使用瀏覽器打開 gradient.html 檔案gradient.compile_to_lowered_stmt("gradient.html", {}, HTML);


==

Lesson 04  - 使用 tracing, print, 與 print_when 來除錯

Var x("x"), y("y");
// 當 Func 計算時印出數值
{
// 如同以往定義 gradient 函數
Func gradient("gradient");
gradient(x, y) = x + y;

// 告知 Halide, 希望收到所有計算的通知.
gradient.trace_stores();

// 以 8x8 大小來實現這個函數
printf("Evaluating gradient\n");
Buffer<int>output = gradient.realize(8, 8);
// Click to show output ...

// 對於每一次的gradeient(x, y)的數值計算都會印出

//如此就可以監看 Halide 的行為,首先嘗試原始的排程.
// 後續會使用以平行方式處理每條 scanline 的新版本
Func parallel_gradient("parallel_gradient");
parallel_gradient(x, y) = x + y;

// 追蹤平行處理版本函數
parallel_gradient.trace_stores();

// 至今只定義了演算法, 但是並沒有談論任何關於排程的事.
// 通常來說, 探索不同的排程方式並不改變演算的描述

//現在告知 Halide 使用平行的 for 迴圈處理 y 座標.
// 在 Linux 平台上執行時會使用 thread pool 與 task queue.
// 而在 OS X 上會使用 grand central dispatch. 對於結果來說是等義的.
parallel_gradient.parallel(y);

// 由於每條 scanline 是由不同的 thread 處理, 因此這次結果將亂序地印出.
// Thread 的數目取決於運作的系統,
// 然而在 Linux 上能夠藉由設定環境變數 HL_NUM_THREADS 來控制
printf("\nEvaluating parallel_gradient\n");
parallel_gradient.realize(8, 8);
// Click to show output ...
}

// 印出個別的 Expr
{
// trace_store() 僅能印出 Func 的數值.
// 有時候會需要檢查內部的表示式而非整個 Func.
// 內建的 print 可以封裝起任何的 Expr 並且在被計算時印出.

// 例如, 對於一些以兩個項目和形成的 Func 
Func f;
f(x, y) = sin(x) + cos(y);

// 若需要監視其中之一的項目, 可以如下使用 print 來僅將其封裝起來
Func g;
g(x, y) = sin(x) + print(cos(y));

printf("\nEvaluating sin(x) + cos(y), and just printing cos(y)\n");
g.realize(4, 4);
// Click to show output ...
}

// 印出額外的內容
{
// print 能使用多個參數, 在計算第一個參數時印所有的參數.
// 參數能夠是 Expr 或是常數字串. 這能夠用來印出除了數值外額外的內容.
Func f;
f(x, y) = sin(x) + print(cos(y), "<- 4="" and="" br="" context="" cos="" f.realize="" is="" more="" n="" nevaluating="" printing="" sin="" this="" when="" with="" x="" y="">// Click to show output ...

// 在像是上方跨越多行的分開表示式很有用,
// 這使得在除錯時能簡單地開關特定數值
Expr e = cos(y);

// 將下列移除註解符號來印出 cos(y) 的數值
// e = print(e, "<- this is cos(", y, ") when x =", x);
}

// 條件列印
{
// 條件印出訊息
// print 與 trace_store 能產生大量的輸出.
// 然而當在尋找少見的事件或是特定事件發生的 pixel, 這樣數量的訊息難以挖掘.
// 相對地, print_when 能夠用來處理條件下的 Expr 訊息輸出.
// 第一個參數為 boolean 型態的 Expr,
// 當該 Expr 數值為 true, 將傳回第二個參數, 並且印出所有參數.
// 若為 false, 則僅傳回第二個參數而不做訊息輸出
Func f;
Expr e = cos(y);
e = print_when(x == 37 && y == 42, e, "<- this is cos(y) at x, y == (37, 42)");
f(x, y) = sin(x) + e;
printf("\nEvaluating sin(x) + cos(y), and printing cos(y) at a single pixel\n");
f.realize(640, 480);
// Click to show output ...

// print_when 也能夠用來檢查你不預期的數值
Func g;
e = cos(y);
e = print_when(e < 0, e, "cos(y) < 0 at y ==", y);
g(x, y) = sin(x) + e;
printf("\nEvaluating sin(x) + cos(y), and printing whenever cos(y) < 0\n");
g.realize(4, 4);
// Click to show output ...
}

// 編譯時期印出表示式
{
// 上述的程式碼僅以數行方式建構 Halide Expr.
// 若以程式方式建構複雜的表示式, 會需要檢查建立的 Expr 是否如所想的一般.
// 這時能夠使用 C++ streams 的方式印出表示式本身
Var fizz("fizz"), buzz("buzz");
Expr e = 1;
for (int i = 2; i < 100; i++) {
    if (i % 3 == 0 && i % 5 == 0) e += fizz*buzz;
    else if (i % 3 == 0) e += fizz;
    else if (i % 5 == 0) e += buzz;
    else e += i;
}
std::cout << "Printing a complex Expr: " << e << "\n";
// Click to show output ...
}

2017年12月31日 星期日

Divergence 與 Convolution/Filtering 的近似在 NEON 的處理

在 2017 年的最後一天想用技術分享做一個結束

Divergence


會特別想提 NEON divergence handling 的原因是許多的 NEON 教學並沒有特別著墨這部份, 儘管並不困難, 但是在眾多 instruction 找出適當的指令也不是容易的事.

對於多數 SIMD instruction 而言 divergence (if-else, switch-cases)都是不容易處理的部份
在許多 modern SIMD ISA 的設計中都採用了 predication 的作法: 可以透過 per-lane flag 數值來個別控制每個 lane 是否執行該指令, 輸出結果.
然而 NEON 並沒有 predication 的設計, 因此對於 if-else 的作法採用的是對於結果做 selection 的方式, 而其中扮演關鍵角色的是 VBSL 這個指令
考量下列範例

unsigned char pix;
...

if(pix >= 192){
     pix += 10;
}else{
     pix +=5;
}
改以 NEON 實作則如下
uint8x16_t vpix;
...
//selection mask
uint8x16_t vsel = vcgeq_u8(vpix, vdupq_n_u8(192));
//for >= 192
vpix1 = vaddq_u8(vpix, vdupq_n_u8(10));
//for < 192
vpix2 = vaddq_u8(vpix, vdupq_n_u8(5));
//get correspond
16-bit Multiply-Accumulation ing result from each lane
vpix = vbslq_u8(vsel, vpix1, vpix2);

事實上 vbsl 的用途不僅如此因為他是 bit-selection, 可以處理 bit-wise operation

16-bit Multiply-Accumulation 

 

這篇第二個要分享的是 fixed point 的技巧, 適合 convolution 與 image filtering 的數值近似.
在 image filtering 與 NN 的 convolution/FC 中的計算, 有許多數值介於 -1.0 ~ 1.0 的浮點數與 整數相乘的處理, 一方面 floating point 本身為 32b 運算(需要 ARMv8.2 才有 NEON FP16 的支援), 再者 float 與 int16/32 的轉換也需要消化額外的指令, 另一方面轉為 int16/32 的 fixed point 處理需要使用更多位元數的 int32/64.

對於許多這類應用有許多乘加的運算, 16bit 的數值相乘需要 32bit 來存放, 然而對於輸出通常也都會到 16bit (也就是最後結果需要 right shift 16 bit), 考量這類應用可以有些微的誤差, 可以考慮一個特別的指令 - vqdmulh or vqrdmulh

vqdmulh 的輸出結果是兩個 16b 數值相乘的 "兩倍" 作 right shift 16 bit
vqrdmulh 相較 vqdmulh 會多做一個 rounding 的動作

對於浮點的處理可以先轉為 16b 的數值 (類似 1/65536 for unsigned or 1/32768 for signed), 而相乘 x2 的結果主要是做 0.5 的近似, 可以取得更好的累加近似結果(最後結果需要 right shift 1bit).  對於一些能事先轉為 16b 資料固定的 pattern (像是 filtering 與 NN 的 weight, 或是除法), 這兩個指令相當實用.
out = 0.14 * pix0 + 0.7 * pix1 + 0.16 * pix2;
轉為 NEON 可以計算近似為:
// 0.14 * pix0
vout = vqdmulh_s16(vpix0, vdupq_n_s16(4588));
// 0.7 * pix1

vout = vaddq_s16(vout, vqdmulh_u16(vpix1, vdupq_n_u16(22934)));
// 0.16 * pix2
vout = vaddq_s16(vout, vqdmulh_u16(vpix2, vdupq_n_u16(5243)));

在 ARM 平台上使用 Function Multi-Versioning (FMV) - 以使用 Android NDK 為例

Function Multi-Versioning (FMV) 過往的 CPU 發展歷程中, x86 平台由於因應各種應用需求的提出, 而陸陸續續加入了不同的指令集, 此外也可能因為針對市場做等級區隔, 支援的數量與種類也不等. 在 Linux 平台上這些 CPU 資訊可以透過...