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個部分,數據讀取與轉換,模型訓練,模型預測和生成結果文件。
- 數據讀取與轉換
- 通過mmap對訓練文件進行映射
- 利用四線程對讀取的字符進行處理,轉爲float存儲
- 模型訓練
- 使用Mini-Batch Gradient Descent (MBGD) 進行梯度下降
- 利用NEON對矩陣運算進行加速
- 模型預測
- 通過mmap對測試文件進行映射
- 利用多線程解析字符同時進行預測
- 多線程中使用NEON解析字符轉爲float
- 生成結果文件
- 使用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會超級慢