2020華爲軟件精英挑戰賽熱身賽總結篇

在這裏插入圖片描述
Hello大家好,這裏是西北賽區“讓心跳動次動次”,我是隊長SUN,先說一下成績吧,熱身賽個人排名56,最後兩天從20+掉到60+,官方查重也沒把我救回前50。初賽西北賽區第四,複賽A榜西北賽區第五,B榜5連WA(0%)。據我所知,西北賽區複賽A榜前8就chier大佬成功晉級決賽,吐槽這裏就不寫了,大佬牛批就完了。

熱身賽一路走來,兩個多月幾乎每天都在認真做比賽,不談最終結果,收穫也是蠻多。日常在Family和LPL羣裏潛水聽各位大佬授課,能結識一些志同道合的小夥伴,這就夠了,20年軟挑再次折戟,明年作爲一隻研三狗不知道還有沒有時間繼續參加。

迴歸正文,這裏是熱身賽總結,熱身賽最後崩盤的主要原因是沒有使用僅讀取部分數據進行預測的trick,導致IO時間過長,不過這並不是重點,以下將分享我在熱身賽中的一些其他優化,初賽和複賽總結將會在下一篇!

00 賽題描述

熱身賽賽題是一個二分類問題,官方已經做好了特徵工程處理,並給出了邏輯迴歸的baseline,需要選手結合對機器學習算法的理解並結合鯤鵬處理器的特點(如:多核、NEON,Cache大小)對其進行優化,準確率高於70%開始計分。

01 整體思路

賽題大體上分爲4個部分,數據讀取與轉換,模型訓練,模型預測和生成結果文件。

  • 數據讀取與轉換
  1. 通過mmap對訓練文件進行映射
  2. 利用四線程對讀取的字符進行處理,轉爲float存儲
  • 模型訓練
  1. 使用Mini-Batch Gradient Descent (MBGD) 進行梯度下降
  2. 利用NEON對矩陣運算進行加速
  • 模型預測
  1. 通過mmap對測試文件進行映射
  2. 利用多線程解析字符同時進行預測
  3. 多線程中使用NEON解析字符轉爲float
  • 生成結果文件
  1. 使用fprintf將預測結果寫入

02 數據讀取與轉換

數據讀取部分,主線程通過mmap獲取數據指針和所有字符數,對所有字符進行4等分,調整使得4個線程從行頭開始解析。

inline bool LR::loadTrainData(){
    char *buf = NULL;
    //獲取文件描述符
    int fd = open(trainFile.c_str(),O_RDONLY);
    if(fd < 0) {
        cout << "打開文件失敗" << endl;
        return false;
    }
    //得到大於901行的字符數,每行字符小於6200。實際訓練只用了901行訓練數據。
    long filelen = 6200 * 901;
    buf = (char *) mmap(NULL, filelen, PROT_READ, MAP_PRIVATE, fd, 0);
    //進行分割,均分爲4份
    int splitNum= filelen / 4 + 1;
    int start2 = splitNum, start3 = 2 * splitNum, start4 = 3 * splitNum, start5 = filelen;
    //需要對開始結束進行調整,便於進行存儲
    while(buf[start2] != '\n') ++start2;
    while(buf[start3] != '\n') ++start3;
    while(buf[start4] != '\n') ++start4;
	while(buf[start5] != '\n') --start5;
    //開啓四個線程
    thread  th1(MultiSplitTrain, buf, 0, start2, trainDataSet1, &trainNum1);
    thread  th2(MultiSplitTrain, buf, start2 + 1, start3, trainDataSet2, &trainNum2);
    thread  th3(MultiSplitTrain, buf, start3 + 1, start4, trainDataSet3, &trainNum3);
    thread  th4(MultiSplitTrain, buf, start4 + 1, start5, trainDataSet4, &trainNum4);
    th1.join();
    th2.join();
    th3.join();
    th4.join();
    //解除映射
	munmap(buf, filelen);
    return true;
}

數據轉換部分,自己寫的轉換函數。

inline void MultiSplitTrain(char* buf, int start, int end, float* data, int* Num){
    int num = 0;
    int n = start;
    while(n < end){
        if(buf[n] == ','){
            ++n;
            continue;
        }
        if(buf[n] == '\n'){
            ++n;
            continue;
        }
        int pos = 0;
        int flag = 1;
        float res = 0.0;
        if(buf[n] == '-'){
             flag = -1;
             n++;
         }
        //此處利用查表避免乘法運算
        res += Mul2Add[buf[n++] - '0'][pos];
        if('.' == buf[n++]){
             ++pos;
            while(buf[n] >= '0' && buf[n] <= '9'){
                if(pos < 6) res += Mul2Add[buf[n++] - '0'][pos++];
            }
         }
        *(data + num++) = flag * res;
    }
    //記錄行數
    *Num = num / 1001;
}

此處的字符轉換函數必須自己寫,庫函數速度相當慢,此外,在進行字符轉化時,利用查表得到浮點數避免乘法運算,表如下:

//查表法 線上數據格式爲x.xxx 所以到0.00x即可
float Mul2Add[10][4] = {
    {0, 0, 0, 0},
    {1, 0.1, 0.01, 0.001},
    {2, 0.2, 0.02, 0.002},
    {3, 0.3, 0.03, 0.003},
    {4, 0.4, 0.04, 0.004},
    {5, 0.5, 0.05, 0.005},
    {6, 0.6, 0.06, 0.006},
    {7, 0.7, 0.07, 0.007},
    {8, 0.8, 0.08, 0.008},
    {9, 0.9, 0.09, 0.009},
};

優化點:
1. mmap讀取
2. 多線程轉換
3. 自寫atof函數
4. 查表

03 模型訓練

單線程,主要利用了NEON對矩陣運算進行加速。

inline void LR::train()
{
	//臨時權重表
    float  WtSet[featuresNum];
    memset(WtSet, 0, sizeof(WtSet));
    float32x4_t traindata_vec;
    float32x4_t wtdata_vec;
    float32x4_t feat_vec;
    float32x4_t WtSet_vec;
    float32x4_t espvInv_vec;
    float32x4_t WtFeature_vec;
    float32x4_t Wtset_vec;
    float32x4_t MulWtSetBatch_vec;
    float32x4_t stepSize_vec;
    float32x4_t batch_vec;
    float32x4_t mul_vec;
	float* trainDataSet;
	//進行迭代
    for (int i = 0; i < maxIterTimes; i++) {
        int start = random(trainNum - batch); //起始位置
        for(int i = start; i < start + batch; ++i){
			//確定內存位置
			if(i < trainNum1) trainDataSet = trainDataSet1 + i * (featuresNum + 1);
			else if(i < trainNum1 + trainNum2) trainDataSet = trainDataSet2 + (i - trainNum1) * (featuresNum + 1);
			else if(i < trainNum1 + trainNum2 + trainNum3) trainDataSet = trainDataSet3 + (i - trainNum1 - trainNum2) * (featuresNum + 1);
			else  trainDataSet = trainDataSet4 + (i - trainNum1 - trainNum2 - trainNum3) * (featuresNum + 1);

			mul_vec = vdupq_n_f32(0.0);
			for(int j = 0; j < featuresNum; j += 4){
				traindata_vec = vld1q_f32(trainDataSet + j);
				wtdata_vec = vld1q_f32(WtFeature + j);
				mul_vec = vmlaq_f32(mul_vec, traindata_vec, wtdata_vec);
			}
			float  mulSum = vgetq_lane_f32(mul_vec, 0)+vgetq_lane_f32(mul_vec, 1)+vgetq_lane_f32(mul_vec, 2)+vgetq_lane_f32(mul_vec, 3);
			float  expvInv = (*(trainDataSet + featuresNum)) - sigmoidCalc(mulSum);
			espvInv_vec = vdupq_n_f32(expvInv);
			for(int j = 0; j < featuresNum; j += 4){
				feat_vec = vld1q_f32(trainDataSet + j);
				WtSet_vec = vld1q_f32(WtSet + j);
				WtSet_vec = vmlaq_f32(WtSet_vec, feat_vec, espvInv_vec);
				vst1q_f32(WtSet + j, WtSet_vec);
			}
		}
		stepSize_vec = vdupq_n_f32(stepSize);
		float vbatch = (float)1 / (float)batch;
		batch_vec = vdupq_n_f32(vbatch);
		for(int j = 0; j < featuresNum; j += 4){
			WtFeature_vec = vld1q_f32(WtFeature + j);
			Wtset_vec = vld1q_f32(WtSet + j);
			MulWtSetBatch_vec = vmulq_f32(Wtset_vec, batch_vec);
			WtFeature_vec = vmlaq_f32(WtFeature_vec, stepSize_vec, MulWtSetBatch_vec);
			vst1q_f32(WtFeature + j, WtFeature_vec);
		}
		memset(WtSet, 0, sizeof(WtSet));
	}
}

這部分代碼因爲加入了大量的NEON運算所以比較難讀,但只要搞懂了NEON的用法看起來就簡單多了,本質是簡單的邏輯迴歸梯度下降。

優化點:
1. NEON加速矩陣運算
2. 減少訓練集的個數,極大地減少讀IO時間,減小batch大小和迭代次數,降低訓練時間。我的參數爲訓練樣本901,學習率0.024,最大迭代次數500,batch爲2,初始權1.2。參數是通過線上測試出來的,卡69.5%正確率。

04 模型預測

測試數據讀取部分和訓練數據讀取類似,不過採用了8線程進行了解析預測,雖然線上評測機只有4核,但經測試8線程比4線程快,考慮可能是負載不均衡導致的。在這裏貼一個NEON解析字符串的魔法,不是自己想出來的,是大佬在羣裏分享的,覺得很有意思。

#include <iostream>
using namespace std;
#include <arm_neon.h>
#include <bits/stdc++.h>

const char T[16] = {0,10,0,10,0,10,0,10,0,10,0,10,0,10,0,10};
const char T2[16] = {100,1,100,1,100,1,100,1,100,1,100,1,100,1,100,1};
const char S1[16] = {64,240,64,240,64,240,64,240,64,240,64,240,64,240,64,240};

char buf[200] = "\n0.245,0.467,1.587,0.456,0.444,0.128,0.111,0.101,0.445,\n";

short read_result[1010];
const int N = 8;
void read(char *pc){
    uint8x16x3_t cval;
    int16x8_t ca;
    uint8x16_t M1 = vld1q_u8((const uint8_t*)T),M2 = vld1q_u8((const uint8_t*)T2),M3 = vld1q_u8((const uint8_t*)S1);
    for(int i=0;i<N;i+=8){
        cval = vld3q_u8((const uint8_t*)pc);
        ca = vreinterpretq_s16_u16(vpaddlq_u8(vaddq_u8(M3, vaddq_u8(vmulq_u8(cval.val[0],M1) , vmulq_u8(cval.val[1],M2)))));
        vst1q_s16(read_result+i,ca);
        pc+=48;
    }
}
int main()
{
    read(buf);
	for(int i = 0; i < 20; ++i) cout << read_result[i] << endl;
    return 0;
}

通過這種方法可以得到所需數據,但因爲線上時間的主要瓶頸在IO,所以對成績影響不大。不過,我相信看懂了這個,基本也就熟悉了NEON的用法,我對測試集的解析也使用了這種思路。

const char T[8] = {10,10,10,10,10,10,10,10};
const char T2[8] = {0,1,0,1,0,1,0,1};
const char T3[8] = {1,0,1,0,1,0,1,0};
const char S2[8] = {100,1,100,1,100,1,100,1};
inline void MultiSplitTest(char* buf, int start, int row, float* WtFeature, int* predict){
	float testData[4];
    uint8x8x3_t cval;
    int32x4_t ca;
	float32x4_t mul_vec;
	float32x4_t wtdata_vec;
	float32x4_t testdata_vec;
    uint8x8_t hund_vec = vdup_n_u8(100);
    uint8x8_t M1 = vld1_u8((const uint8_t*)T),M2 = vld1_u8((const uint8_t*)T2),M3 = vld1_u8((const uint8_t*)T3),M4 = vdup_n_u8(240),M5 = vld1_u8((const uint8_t*)S2);
	int N = 1000;
	int n = start;
	for(int i = 0; i < row; ++i){
		mul_vec = vdupq_n_f32(0.0);
		for(int j = 0; j < N; j += 4){
			cval = vld3_u8((const uint8_t*)(buf + n));
			ca = vreinterpretq_s32_u32(vpaddlq_u16(vmull_u8(vadd_u8(M4, vadd_u8(vadd_u8(vmul_u8(cval.val[0],M1) , vmul_u8(cval.val[1],M2)), vmul_u8(cval.val[2],M3))), M5)));
			testData[0] = int2Float[vgetq_lane_s32(ca, 0)];
			testData[1] = int2Float[vgetq_lane_s32(ca, 1)];
			testData[2] = int2Float[vgetq_lane_s32(ca, 2)];
			testData[3] = int2Float[vgetq_lane_s32(ca, 3)];
			testdata_vec = vld1q_f32(testData);
			wtdata_vec = vld1q_f32(WtFeature + j);
			mul_vec = vmlaq_f32(mul_vec, testdata_vec, wtdata_vec);
			n += 24;
		}
		float  mulSum = vgetq_lane_f32(mul_vec, 0)+vgetq_lane_f32(mul_vec, 1)+vgetq_lane_f32(mul_vec, 2)+vgetq_lane_f32(mul_vec, 3);
		*(predict + i) = mulSum >= 0 ? 1 : 0;
	}
}

優化點:
1. mmap映射讀文件
2. 讀取不必全讀3位小數
3. 多線程解析
4. 邊讀邊預測
5. NEON在解析中的用法

05 生成結果文件

inline int LR::storePredict()
{
    FILE* f;
    f = fopen(predictOutFile.c_str(), "w");
    if(NULL == f){
        cout << "storePredict error" << endl;
        return false;
    }
    for (int i = 0; i < testNum; i++) {
        fprintf(f, "%d\n", *(predictVec + i));
    }
    fclose(f);
    return 0;
}

最後,開源請點擊這裏,經過初賽複賽的訓練,發現這份代碼還有一些點可以優化,比如取消類、多線程訓練、全局靜態數組等等,但更重要的還是IO時間,大佬們基本都用了僅讀取第一維特徵去預測,減少了大量的讀測試數據IO時間。針對鯤鵬處理器的優化,我利用了多核和NEON,至於針對Cache的優化,自己也是一知半解,部分思路將會在初賽分享中給出,敬請期待~

補充,學習了大佬們的開源,整理一下:

大佬們的trick合集:

1. 可僅通過第一維對數據進行預測
2. 數字可以只讀小數點後一位,忽略其他所有數位
3. 測試集中沒有負號,推測訓練集中帶符號的數據沒有用,直接忽略
4. switch-case比if-else要快一些
5. memcpy比字符數組挨個賦值要快
6. 預測時,多進程比多線程快
7. 輸出到文件中的換行符用endl會超級慢

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章