Hello大家好,这里是西北赛区“让心跳动次动次”,我是队长SUN,先说一下成绩吧,热身赛个人排名56,最后两天从20+掉到60+,官方查重也没把我救回前50。初赛西北赛区第四,复赛A榜西北赛区第五,B榜5连WA(0%)。据我所知,西北赛区复赛A榜前8就chier大佬成功晋级决赛,吐槽这里就不写了,大佬牛批就完了。
热身赛一路走来,两个多月几乎每天都在认真做比赛,不谈最终结果,收获也是蛮多。日常在Family和LPL群里潜水听各位大佬授课,能结识一些志同道合的小伙伴,这就够了,20年软挑再次折戟,明年作为一只研三狗不知道还有没有时间继续参加。
回归正文,这里是热身赛总结,热身赛最后崩盘的主要原因是没有使用仅读取部分数据进行预测的trick,导致IO时间过长,不过这并不是重点,以下将分享我在热身赛中的一些其他优化,初赛和复赛总结将会在下一篇!
热身赛赛题是一个二分类问题,官方已经做好了特征工程处理,并给出了逻辑回归的baseline,需要选手结合对机器学习算法的理解并结合鲲鹏处理器的特点(如:多核、NEON,Cache大小)对其进行优化,准确率高于70%开始计分。
赛题大体上分为4个部分,数据读取与转换,模型训练,模型预测和生成结果文件。
数据读取部分,主线程通过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. 查表
单线程,主要利用了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%正确率。
测试数据读取部分和训练数据读取类似,不过采用了8线程进行了解析预测,虽然线上评测机只有4核,但经测试8线程比4线程快,考虑可能是负载不均衡导致的。在这里贴一个NEON解析字符串的魔法,不是自己想出来的,是大佬在群里分享的,觉得很有意思。
#include
using namespace std;
#include
#include
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在解析中的用法
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会超级慢