前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >移动端arm cpu优化学习笔记----一步步优化盒子滤波(Box Filter) 顶

移动端arm cpu优化学习笔记----一步步优化盒子滤波(Box Filter) 顶

作者头像
Ldpe2G
发布于 2019-05-14 07:19:29
发布于 2019-05-14 07:19:29
1.2K00
代码可运行
举报
运行总次数:0
代码可运行

            最近一段时间做比较多移动端开发相关的工作,感觉移动端优化相关的对我来说挺有趣的,

以前都是在PC上写代码,写代码的时候对于代码的性能没有过多的思考和感觉。但是在移动端上

写代码明显能察觉到一段代码写的好不好,对于在移动端上运行性能有很大的影响,尤其在一些

比较老旧的机型上测试更能有感觉。

           然后最近刚好在复现一篇论文,要在MXNet中实现类似盒子滤波(box filter)的操作子,其实

就是步长为1的sum pooling,盒子滤波算是很基础和经典的函数,但是在PC上实现的话因为有GPU

借助其强大的算力所以可以很暴力的实现,每个thread计算以某点为中心给定半径下的区域大小的和即可。

然后突发奇想想试试在移动端cpu上试试如何写高效的盒子滤波操作。

              这篇文章就是把我的实践过程记录下来,首先给出最简单的实现然后如何一步步优化,到最后给出

一个性能优化还不错的版本。由于我正式接触移动端优化的时间不长,很多东西理解的不深,所以有哪里

论述不正确的地方请读者指出。

本文的代码:BoxFilter

1.Boxfilter最简单实现

首先来看下Boxfilter最简单最直观的实现:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void BoxFilter::filter(float *input, int radius, int height, int width, float *output) {
  for (int h = 0; h < height; ++h) {
    int height_sift = h * width;
    for (int w = 0; w < width; ++w) {
      int start_h = std::max(0, h - radius);
      int end_h = std::min(height - 1, h + radius);
      int start_w = std::max(0, w - radius);
      int end_w = std::min(width - 1, w + radius);

      float tmp = 0;
      for (int sh = start_h; sh <= end_h; ++sh) {
        for (int sw = start_w; sw <= end_w; ++ sw) {
          tmp += input[sh * width + sw];
        }
      }

      output[height_sift + w] = tmp;
    }
  }
}

对每个点,计算给定半径下区域的和,需要注意下边界的处理。

其时间复杂度是 O( height x width x (radius x 2 + 1) x (radius x 2 + 1) ),

这个最简单的实现在输入大小固定的情况下,半径越大耗时越大,有很多重复计算的地方,相邻元素

在计算各自区域内和的时候其实是有重叠的。然后第一个优化的思路就是boxfilter的计算是行列可

分离的,具体可参考[4]。

2.Boxfilter优化第一版

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void BoxFilter::fastFilter(float *input, int radius, int height, int width, float *output) {
  float *cachePtr = &(cache[0]);
  // sum horizonal
  for (int h = 0; h < height; ++h) {
    int sift = h * width;
    for (int w = 0; w < width; ++w) {
      int start_w = std::max(0, w - radius);
      int end_w = std::min(width - 1, w + radius);

      float tmp = 0;
      for (int sw = start_w; sw <= end_w; ++ sw) {
        tmp += input[sift + sw];
      }

      cachePtr[sift + w] = tmp;
    }
  }

  // sum vertical
  for (int h = 0; h < height; ++h) {
    int shift = h * width;
    int start_h = std::max(0, h - radius);
    int end_h = std::min(height - 1, h + radius);

    for (int sh = start_h; sh <= end_h; ++sh) {
      int out_shift = sh * width;
      for (int w = 0; w < width; ++w) {
        output[out_shift + w] += cachePtr[shift + w];
      }
    }
  }
}

所谓行列可分离就是,把行列分开计算,从代码里可以看到,对每个元素,首先计算行方向上半径

内的和,然后再计算列半径内的和,

所以这时候的时间复杂度是O(height x width x (radius x 2 + 1) x 2)。

可以看到行列分离之后,时间复杂度减少了不少,尤其半径越大减少的越多,但是还是有重复计

算的地方,而且在固定输入下时间复杂度还是会随半径的变大而变大。那么有没有方法可以使得计算

复杂度不受半径的影响呢,这个优化思路就是比如在算某一行每个点的半径区域内的和时,对于行开头

第一个点,首先计算其半径内和,然后对于接下来的点,不需要重新计算其半径区域内和,而是只需要

把前一个元素半径内的和,按半径窗口偏移之后减去旧的点和加上新加入的点即可。

3.Boxfilter优化第二版

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void BoxFilter::fastFilterV2(float *input, int radius, int height, int width, float *output) {
  float *cachePtr = &(cache[0]);
  // sum horizonal
  for (int h = 0; h < height; ++h) {
    int shift = h * width;

    float tmp = 0;
    for (int w = 0; w < radius; ++w) {
      tmp += input[shift + w];
    }

    for (int w = 0; w <= radius; ++w) {
      tmp += input[shift + w + radius];
      cachePtr[shift + w] = tmp;
    }

    int start = radius + 1;
    int end = width - 1 - radius;
    for (int w = start; w <= end; ++w) {
      tmp += input[shift + w + radius];
      tmp -= input[shift + w - radius - 1];
      cachePtr[shift + w] = tmp;
    }

    start = width - radius;
    for (int w = start; w < width; ++w) {
      tmp -= input[shift + w - radius - 1];
      cachePtr[shift + w] = tmp;
    }
  }

  float *colSumPtr = &(colSum[0]);
  for (int indexW = 0; indexW < width; ++indexW) {
    colSumPtr[indexW] = 0;
  } 
  // sum vertical
  for (int h = 0; h < radius; ++h) {
    int shift = h * width;
    for (int w = 0; w < width; ++w) {
      colSumPtr[w] += cachePtr[shift + w];
    }
  }

  for (int h = 0; h <= radius; ++h) {
    float *addPtr = cachePtr + (h + radius) * width;
    int shift = h * width;
    float *outPtr = output + shift; 
    for (int w = 0; w < width; ++w) {
      colSumPtr[w] += addPtr[w];
      outPtr[w] = colSumPtr[w];
    }
  }

  int start = radius + 1;
  int end = height - 1 - radius;
  for (int h = start; h <= end; ++h) {
    float *addPtr = cachePtr + (h + radius) * width;
    float *subPtr = cachePtr + (h - radius - 1) * width;
    int shift = h * width;
    float *outPtr = output + shift;
    for (int w = 0; w < width; ++w) {
      colSumPtr[w] += addPtr[w];
      colSumPtr[w] -= subPtr[w];
      outPtr[w] = colSumPtr[w];
    }
  }

  start = height - radius;
  for (int h = start; h < height; ++h) {
    float *subPtr = cachePtr + (h - radius - 1) * width;
    int shift = h * width;
    float *outPtr = output + shift; 
    for (int w = 0; w < width; ++w) {
      colSumPtr[w] -= subPtr[w];
      outPtr[w] = colSumPtr[w];
    }
  }
}

这一版时间复杂度大概是O(height x width x 4 ),不算边界只看中间部分的计算就是一次加法和一次减法,

行方向和列方向都一样。这里行方向的部分很好理解,因为边界部分需要特殊处理,比如开始部分只有加,

结尾部分只有减法,所以计算分成了3部分。列方向计算的话按照常规思路,那就是按一列列来处理,可是

我们知道数据一般是按照行来存储的,这样子跳行取数据,会造成很多次cache miss,这样子性能肯定会受

很大的影响,所以这里用了一个大小是width的向量colSum,来存储每一列对应点的半径区域内的和,

然后遍历的时候还是按照行来遍历,如果一下子理解不了这个思路的话,可以想象如果width为1的情况,

那么应该可以更好的理解。

然后我们来看下实验结果,这三版boxfilter在输入是2000x2000的情况下,在不同半径下的运行耗时,

测试手机是华为荣耀4C(CHM-TL00),每个函数运行10次取平均为其耗时:

可以看到第二版优化的耗时在不同半径下的表现都很稳定,基本不受影响。然后接下来的优化思路就是在

确定了C++ 的代码之后可以采用arm Neon Intrinsics来加速了,就是利用向量计算指令同时处理多个数据,

把独立的运算同时做,比写汇编要容易。

4.Boxfilter优化第二版 Neon Intrinsics

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  int n = width >> 2;
  int re = width - (n << 2);
  
  int start = radius + 1;
  int end = height - 1 - radius;
  for (int h = start; h <= end; ++h) {
    float *addPtr = cachePtr + (h + radius) * width;
    float *subPtr = cachePtr + (h - radius - 1) * width;
    int shift = h * width;
    float *outPtr = output + shift; 
    int indexW = 0;
    float *tmpOutPtr = outPtr;
    float *tmpColSumPtr = colSumPtr;
    float *tmpAddPtr = addPtr;
    float *tmpSubPtr = subPtr;

    int nn = n;
    int remain = re;
#if __ARM_NEON
    for (; nn > 0; nn--) {
      float32x4_t _add = vld1q_f32(tmpAddPtr);
      float32x4_t _sub = vld1q_f32(tmpSubPtr);
      float32x4_t _colSum = vld1q_f32(tmpColSumPtr);

      float32x4_t _tmp = vaddq_f32(_colSum, _add);
      _tmp = vsubq_f32(_tmp, _sub);

      vst1q_f32(tmpColSumPtr, _tmp);
      vst1q_f32(tmpOutPtr, _tmp);

      tmpAddPtr += 4;
      tmpSubPtr += 4;
      tmpColSumPtr += 4;
      tmpOutPtr += 4;
    }
#endif // __ARM_NEON
    for (; remain > 0; --remain) {
      *tmpColSumPtr += *tmpAddPtr;
      *tmpColSumPtr -= *tmpSubPtr;
      *tmpOutPtr = *tmpColSumPtr;
      tmpAddPtr ++;
      tmpColSumPtr ++;
      tmpOutPtr ++;
      tmpSubPtr ++;
    }
  }

上面的代码是截取列方向中间计算部分来展示如何使用arm Neon Intrinsics函数,

完整代码可以看

https://github.com/Ldpe2G/ArmNeonOptimization/blob/master/boxFilter/src/boxFilter.cpp#L143

而行方向是没办法并行的,因为相邻元素有依赖,而列方向则可以,所以在列方向上做改写。

以上代码其实挺好理解的,vld1q_f32指令就是加载4个浮点数,然后vaddq_f32,为把两个float32x4_t

向量相加,相当于同时计算了4个输出,然后再把结果用vst1q_f32存回去对应的地址,然后所有参与运算

的地址都是每次加4,具体可以参考官网文档

然后来看下这版优化的耗时如何:

可以看到耗时又少了一点,但是收益已经不大了。然后还想尝试进一步优化把Intrinsics部分改写

成内联汇编试试。

4.Boxfilter优化第二版 Neon Assembly

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  int n = width >> 2;
  int re = width - (n << 2);
  
  int start = radius + 1;
  int end = height - 1 - radius;
  for (int h = start; h <= end; ++h) {
    float *addPtr = cachePtr + (h + radius) * width;
    float *subPtr = cachePtr + (h - radius - 1) * width;
    int shift = h * width;
    float *outPtr = output + shift; 
    int indexW = 0;
    float *tmpOutPtr = outPtr;
    float *tmpColSumPtr = colSumPtr;
    float *tmpAddPtr = addPtr;
    float *tmpSubPtr = subPtr;

    int nn = n;
    int remain = re;
#if __ARM_NEON
    asm volatile(
      "0:                       \n"
      "vld1.s32 {d0-d1}, [%0]!  \n"
      "vld1.s32 {d2-d3}, [%1]!  \n"
      "vld1.s32 {d4-d5}, [%2]   \n"
      "vadd.f32 q4, q0, q2      \n"
      "vsub.f32 q3, q4, q1      \n"
      "vst1.s32 {d6-d7}, [%3]!  \n"
      "vst1.s32 {d6-d7}, [%2]!  \n"
      "subs %4, #1              \n"
      "bne  0b                  \n"
      : "=r"(tmpAddPtr), //
      "=r"(tmpSubPtr),
      "=r"(tmpColSumPtr),
      "=r"(tmpOutPtr),
      "=r"(nn)
      : "0"(tmpAddPtr),
      "1"(tmpSubPtr),
      "2"(tmpColSumPtr),
      "3"(tmpOutPtr),
      "4"(nn)
      : "cc", "memory", "q0", "q1", "q2", "q3", "q4"
    );

#endif // __ARM_NEON
    for (; remain > 0; --remain) {
      *tmpColSumPtr += *tmpAddPtr;
      *tmpColSumPtr -= *tmpSubPtr;
      *tmpOutPtr = *tmpColSumPtr;
      tmpAddPtr ++;
      tmpColSumPtr ++;
      tmpOutPtr ++;
      tmpSubPtr ++;
    }
  }

完整版代码:https://github.com/Ldpe2G/ArmNeonOptimization/blob/master/boxFilter/src/boxFilter.cpp#L331

这里我只对列计算中间部分做了改写,neon汇编下面的"cc","memory"之后跟的寄存器,是为了告诉编译器

(主要是q开头的,q和d是一样的,q表示128位向量寄存器(16个),d表示64位(32个),q0 =(d0 + d1)),

这些寄存器会在汇编内被用到,然后编译器在进入这段代码之前,要缓存这些寄存器的内容,然后在离开这段汇编

之后恢复原来的值。一定要记得写上用了哪些向量寄存器。 汇编指令其实和intrinsics函数有对应的具体可参考

官方文档. 然后我们来看下耗时:

什么鬼,竟然还慢了,一定是我使用的方式不对。去查了下资料,看到这篇博客里面提到,指令vld和vst都是需要

消耗两个时钟周期,其他指令基本都是一个时钟周期,但是却不意味着一个时钟周期之后能立刻得到结果。那么

看下来vsub.f32 指令依赖 vadd.f32 的结果,所以白白浪费了不少时钟周期。而且现代的处理器支持双发射流水线

也就意味着CPU可以同时执行两条数据无关指令,那么能否利用这点来更进一步加速呢。

5.Boxfilter优化第二版 Neon Assembly 第二版

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  int start = radius + 1;
  int end = height - 1 - radius;
  for (int h = start; h <= end; ++h) {
    float *addPtr = cachePtr + (h + radius) * width;
    float *subPtr = cachePtr + (h - radius - 1) * width;
    int shift = h * width;
    float *outPtr = output + shift; 
    int indexW = 0;
    float *tmpOutPtr = outPtr;
    float *tmpColSumPtr = colSumPtr;
    float *tmpAddPtr = addPtr;
    float *tmpSubPtr = subPtr;

    int nn = width >> 3;
    int remain = width - (nn << 3);
#if __ARM_NEON
    asm volatile(
      "0:                       \n"
      "pld      [%0, #256]      \n"
      "vld1.s32 {d0-d3}, [%0]!  \n"
      "pld      [%2, #256]      \n"
      "vld1.s32 {d8-d11}, [%2]  \n"

      "vadd.f32 q6, q0, q4      \n"

      "pld      [%1, #256]      \n"
      "vld1.s32 {d4-d7}, [%1]!  \n"
      
      "vadd.f32 q7, q1, q5      \n"
      
      "vsub.f32 q6, q6, q2      \n"
      
      "vsub.f32 q7, q7, q3      \n"
      
      "vst1.s32 {d12-d15}, [%3]!  \n"
      
      "vst1.s32 {d16-d19}, [%2]!  \n"

      "subs %4, #1              \n"
      "bne  0b                  \n"
      : "=r"(tmpAddPtr), //
      "=r"(tmpSubPtr),
      "=r"(tmpColSumPtr),
      "=r"(tmpOutPtr),
      "=r"(nn)
      : "0"(tmpAddPtr),
      "1"(tmpSubPtr),
      "2"(tmpColSumPtr),
      "3"(tmpOutPtr),
      "4"(nn)
      : "cc", "memory", "q0", "q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8", "q9"
    );

#endif // __ARM_NEON
    for (; remain > 0; --remain) {
      *tmpColSumPtr += *tmpAddPtr;
      *tmpColSumPtr -= *tmpSubPtr;
      *tmpOutPtr = *tmpColSumPtr;
      tmpAddPtr ++;
      tmpColSumPtr ++;
      tmpOutPtr ++;
      tmpSubPtr ++;
    }
  }

完整版代码:

https://github.com/Ldpe2G/ArmNeonOptimization/blob/master/boxFilter/src/boxFilter.cpp#L527

可以看到这里的改进思路就是,把两条 vadd.f32 指令放一起,然后跟两条vsub.f32,然后把加载 vsub.f32 要用

到部分数据指令 vld1.s32 放到两个 vadd.f32之间,同时 vld1.s32 指令之前加上 pld 指令,这个指令为什么能加速

我问了下做移动端优化的同事,pld把数据从内存加载到cache然后下一条指令把数据从 cache加载到寄存器,

如果不用pld,数据若不在cache中,那么就是需要直接从内存加载到寄存器,这里会比前者慢很多。

然后我们来看下最终版的耗时:

看表格最终版的耗时比起最原始的实现至少可以加速6~7倍,肯定是还有更好的优化方式,比如如果能对输入做

量化把float类型数据转成8bit整型,那么就可以在单位时间处理更多数据,当然量化到8bit上计算溢出的风险也会

增大许多。有时候炼丹炼久了,学习下优化也挺好玩的,感觉可以很好的锻炼下思维和代码能力,现在深度学习

移动端应用越来越广泛,训出来的模型如果部署到移动端之后运行的效率很低那么也是白费功夫。所以感觉对移动端

优化有一定的了解对于如何设计对移动端更友好的模型还是有帮助的。

相关资料:

[1]小鱼干:ARM NEON 优化

[2] https://azeria-labs.com/writing-arm-assembly-part-1/​azeria-labs.com

[3]http://armneon.blogspot.com/2013/07/neon-tutorial-part-1-simple-function_13.html

[4]解析opencv中Box Filter的实现并提出进一步加速的方案(源码共享)

[5]ARM Information Center

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
4.HTML样式布局区块标签元素介绍
本章节,主要介绍HTML布局与区块的元素介绍, 比如头部,中部,尾部以及行内区块与行外区块等,具体讲解如下述所示。
全栈工程师修炼指南
2023/03/21
1.4K0
4.HTML样式布局区块标签元素介绍
Html5+CSS3命名规范(前端web开发命名规范,符合SEO规范)
任何代码编程都有各自特点的常用命名规范,div+css页面设计也不例外。遵守常用的css命名规范有利于代码的升级和扩展,也有利于让别人读懂你的css代码,让你的页面显得清晰有条理。
李洋博客
2021/06/15
1.8K0
HTML5+CSS3命名规范
任何代码编程都有各自特点的常用命名规范,div+css页面设计也不例外。遵守常用的css命名规范有利于代码的升级和扩展,也有利于让别人读懂你的css代码,让你的页面显得清晰有条理。
闲花手札
2021/08/24
7410
谷歌 HTML/CSS 规范
这篇文章定义了 HTML 和 CSS 的格式和代码规范,旨在提高代码质量和协作效率。 通用样式规范 协议 省略图片、样式、脚本以及其他媒体文件 URL 的协议部分(http:,https:),除非文件
用户1667431
2018/04/18
2.2K0
谷歌 HTML/CSS 规范
前端开发面试题
本文由我收集总结了一些前端面试题,初学者阅后也要用心钻研其中的原理,重要知识需要系统学习、透彻学习,形成自己的知识链。万不可投机取巧,临时抱佛脚只求面试侥幸混过关是错误的!也是不可能的!不可能的!不可能的!
用户1065635
2019/03/21
5.1K0
前端开发面试题
记录前端开发过程中遇到的一个问题
博客新增菜单后,点击新菜单后其中的js不生效,刷新页面后才生效,这个页面是通过<a href="xxx" target=_self>标签跳转过来的。下面上我的代码
晓果冻
2022/09/08
2770
记录前端开发过程中遇到的一个问题
前端开发JavaScript-巩固你的JavaScript
在javascript中,变量是存储信息的容器,变量存在两种类型的值,即为原始值和引用值。
达达前端
2020/04/02
2.9K0
前端开发JavaScript-巩固你的JavaScript
BiugleJS
老猫-Leo
2023/12/11
1660
vue项目前端规范
// 数组解构赋值 const arr = [1, 2, 3, 4] // bad const first = arr[0] const second = arr[1] // good const [first, second] = arr
薛定喵君
2020/03/30
2.6K0
前端JS规范
在我们现在所有的工程中都配置了eslint校验命令: npm run lint / npm run lintfix, 区别只是一个只做eslint验证,一个是会主动fix部分问题
默默的成长
2022/10/29
5.4K0
Javascript编码规范建议
示例: 解释: 声明包含元素的数组与对象,只有当内部元素的形式较为简单时,才允许写在一行。元素复杂的情况,还是应该换行书写。 示例:
Clearlove
2019/08/29
1.4K0
Javascript编码规范建议
HTML+CSS【规范】
div、h1~h6、address、blockquote、center、dir、dl、dt、dd、fieldset、form、hr、isindex、menu、noframes、noscript、ol、p、pre、table、ul … 特点:总是在新行上开始,高度、行高以及顶和底边距都可控制,宽度缺省是它的容器的100%,除非设定一个宽度 功能:主要用来搭建网站架构、页面布局、承载内容
MIKE笔记
2023/03/23
8200
HTML+CSS【规范】
最全面的前端开发指南
HTML 语义 HTML5为我们提供了很多旨在精确描述内容的语义元素。确保你可以从它丰富的词汇中获益。 <!-- bad --> <div id="main"> <div class="article"> <div class="header"> <h1>Blog post</h1> <p>Published: <span>21st Feb, 2015</span></p> </div> <p>…</p> </div> </div> <!-- go
前朝楚水
2018/04/02
7750
【开发规范系列】(四)前端开发规范
现代软件架构的复杂性需要协同开发完成,如何高效地协同呢?无规矩不成方圆,无规范难以协同,比如,制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全,试想如果没有限速,没有红绿灯,谁还敢上路行驶。对软件来说,适当的规范和标准绝不是消灭代码内容的创造性、优雅性,而是限制过度个性化,以一种普遍认可的统一方式一起做事,提升协作效率,降低沟通成本。代码的字里行间流淌的是软件系统的血液,质量的提升是尽可能少踩坑,杜绝踩重复的坑,切实提升系统稳定性,码出质量。
程序员朱永胜
2023/09/01
8000
制定自己团队的前端开发规范
后续的文章我也补充齐全了,一共有两篇,都是实战篇,一篇是制定自己团队的前端开发规范之 eslint,另外一篇是手摸手带你实践标准的前端开发规范,希望大家可以去看一下,然后把这套规范实践起来,让自己的开发存在更少的bug。
用户1462769
2019/08/09
9130
Web前端开发代码规范(基础)
对于一个多人团队来说,制定一个统一的规范是必要的,因为个性化的东西无法产生良好的聚合效果,规范化可以提高编码工作效率,使代码保持统一的风格,以便于代码整合和后期维护。
用户7705674
2021/09/19
2.1K0
Web前端开发规范手册
为提高团队协作效率, 便于后台人员添加功能及前端后期优化维护, 输出高质量的文档, 特制订此文档。
用户7705674
2021/09/19
2.7K0
前端开发知识汇总--JS
版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/j_bleach/article/details/67642677
j_bleach
2019/07/02
1.3K0
前端开发规范Javascript
文件注释用于告诉不熟悉这段代码的读者这个文件中包含哪些东西。 应该提供文件的大体内容, 它的作者, 依赖关系和兼容性信息。如下:
PM吃瓜
2019/08/12
6980
前端代码规范(es6,eslint,vue)
为每个 HTML 页面的第一行添加标准模式(standard mode)的声明,这样能够确保在每个浏览器中拥有一致的展现。
山河木马
2019/03/05
6K0
前端代码规范(es6,eslint,vue)
相关推荐
4.HTML样式布局区块标签元素介绍
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
查看详情【社区公告】 技术创作特训营有奖征文