PyTorch的Debug指南


Posted in Python onMay 07, 2021

一、ipdb 介绍

很多初学 python 的同学会使用 print 或 log 调试程序,但是这只在小规模的程序下调试很方便,更好的调试应该是在一边运行的时候一边检查里面的变量和方法。

感兴趣的可以去了解 pycharm 的 debug 模式,功能也很强大,能够满足一般的需求,这里不多做赘述,我们这里介绍一个更适用于 pytorch 的一个灵活的 pdb 交互式调试工具。

Pdb 是一个交互式的调试工具,集成与 Python 标准库中,它能让你根据需求跳转到任意的 Python 代码断点、查看任意变量、单步执行代码,甚至还能修改变量的值,而没有必要去重启程序。

ipdb 则是一个增强版的 pdb,它提供了调试模式下的代码自动补全,还有更好的语法高亮和代码溯源,以及更好的内省功能,最重要的是它和 pdb 接口完全兼容,可以通过 pip install ipdb 安装。

二、ipdb 的使用

首先看一个例子,要使用 ipdb 的话,只需要在想要进行调试的地方插入 ipdb.set_trace(),当代码运行到这个地方时,就会自动进入交互式调试模式。

import ipdb


def sum(x):
    r = 0
    for ii in x:
        r += ii
    return r


def mul(x):
    r = 1
    for ii in x:
        r *= 11
    return r


ipdb.set_trace()
x = [1, 2, 3, 4, 5]
r = sum(x)
r = mul(x)
> /Users/mac/Desktop/jupyter/test.py(19)<module>()
     18 ipdb.set_trace()
---> 19 x = [1, 2, 3, 4, 5]
     20 r = sum(x)

ipdb> l 1,5  # l(ist) 1,5 的缩写,查看第 1 行到第 5 行的代码
      1 import ipdb
      2 
      3 
      4 def sum(x):
      5     r = 0

ipdb> n  # n(ext) 的缩写执行下一步
> /Users/mac/Desktop/jupyter/test.py(20)<module>()
     19 x = [1, 2, 3, 4, 5]
---> 20 r = sum(x)
     21 r = mul(x)

ipdb> s  # s(tep) 的缩写,进入 sum 函数内部
--Call--
> /Users/mac/Desktop/jupyter/test.py(4)sum()
      3 
----> 4 def sum(x):
      5     r = 0

ipdb> n  # n(ext) 单步执行
> /Users/mac/Desktop/jupyter/test.py(5)sum()
      4 def sum(x):
----> 5     r = 0
      6     for ii in x:

ipdb> n
> /Users/mac/Desktop/jupyter/test.py(6)sum()
      5     r = 0
----> 6     for ii in x:
      7         r += ii

ipdb> u  # u(p) 的缩写,调回上一层的调用
> /Users/mac/Desktop/jupyter/test.py(20)<module>()
     19 x = [1, 2, 3, 4, 5]
---> 20 r = sum(x)
     21 r = mul(x)

ipdb> d  # d(own) 的缩写,跳到调用的下一层
> /Users/mac/Desktop/jupyter/test.py(6)sum()
      5     r = 0
----> 6     for ii in x:
      7         r += ii

ipdb> n
> /Users/mac/Desktop/jupyter/test.py(7)sum()
      6     for ii in x:
----> 7         r += ii
      8     return r

ipdb> !r  # 查看变量 r 的值,该变量名与调试命令 `r(eturn)` 冲突
0
    
ipdb> return  # 继续运行知道函数返回
--Return--
15
> /Users/mac/Desktop/jupyter/test.py(8)sum()
      7         r += ii
----> 8     return r
      9 

ipdb> n
> /Users/mac/Desktop/jupyter/test.py(21)<module>()
     19 x = [1, 2, 3, 4, 5]
     20 r = sum(x)
---> 21 r = mul(x)

ipdb> x  # 查看变量 x
[1, 2, 3, 4, 5]
    
ipdb> x[0] = 10000  # 修改变量 x
    
ipdb> x
[10000, 2, 3, 4, 5]
    
ipdb> b 12  # b(reak) 的缩写,在第 10 行设置断点
Breakpoint 1 at /Users/mac/Desktop/jupyter/test.py:12
    
ipdb> c  # c(ontinue) 的缩写,继续运行,直到遇到断点
> /Users/mac/Desktop/jupyter/test.py(12)mul()
     11 def mul(x):
1--> 12     r = 1
     13     for ii in x:

ipdb> return  # 可以看到计算的是修改之后的 x 的乘积
--Return--
1200000
> /Users/mac/Desktop/jupyter/test.py(15)mul()
     14         r *= ii
---> 15     return r
     16 

ipdb> q  # q(uit) 的缩写,退出 debug

上述只是给出了 ipdb 的一部分使用方法,关于 ipdb 还有一些小的使用技巧:

  • 键能够自动补齐,补齐用法和 IPython 中的类似
  • j(ump) 能够跳过中间某些行的代码的执行
  • 可以直接在 ipdb 中修改变量的值
  • help 能够查看调试命令的用法,比如 h h 可以查看 help 命令的用法,h j(ump) 能够查看 j(ump) 命令的用法

三、在 PyTorch 中 Debug

PyTorch 作为一个动态图框架,和 ipdb 结合使用能够让调试过程更加便捷,下面我们将距离说明以下三点:

  • 如何在 PyTorch 中查看神经网络各个层的输出
  • 如何在 PyTorch 中分析各个参数的梯度
  • 如何动态修改 PyTorch 的训练流程

首先,运行上一篇文章给出的“猫狗大战”程序:python main.py train --debug-file='debug/debug.txt'

程序运行一段时间后,在debug目录下创建debug.txt标识文件,当程序检测到这个文件存在时,会自动进入debug模式。

99it [00:17,  6.07it/s]loss: 0.22854854568839075
119it [00:21,  5.79it/s]loss: 0.21267264398435753
139it [00:24,  5.99it/s]loss: 0.19839374726372108
> e:/Users/mac/Desktop/jupyter/mdFile/deeplearning/main.py(80)train()
     79         loss_meter.reset()
---> 80         confusion_matrix.reset()
     81         for ii, (data, label) in tqdm(enumerate(train_dataloader)):

ipdb> break 88    # 在第88行设置断点,当程序运行到此处进入debug模式
Breakpoint 1 at e:/Users/mac/Desktop/jupyter/mdFile/deeplearning/main.py:88

ipdb> # 打印所有参数及其梯度的标准差
for (name,p) in model.named_parameters(): \
    print(name,p.data.std(),p.grad.data.std())
model.features.0.weight tensor(0.2615, device='cuda:0') tensor(0.3769, device='cuda:0')
model.features.0.bias tensor(0.4862, device='cuda:0') tensor(0.3368, device='cuda:0')
model.features.3.squeeze.weight tensor(0.2738, device='cuda:0') tensor(0.3023, device='cuda:0')
model.features.3.squeeze.bias tensor(0.5867, device='cuda:0') tensor(0.3753, device='cuda:0')
model.features.3.expand1x1.weight tensor(0.2168, device='cuda:0') tensor(0.2883, device='cuda:0')
model.features.3.expand1x1.bias tensor(0.2256, device='cuda:0') tensor(0.1147, device='cuda:0')
model.features.3.expand3x3.weight tensor(0.0935, device='cuda:0') tensor(0.1605, device='cuda:0')
model.features.3.expand3x3.bias tensor(0.1421, device='cuda:0') tensor(0.0583, device='cuda:0')
model.features.4.squeeze.weight tensor(0.1976, device='cuda:0') tensor(0.2137, device='cuda:0')
model.features.4.squeeze.bias tensor(0.4058, device='cuda:0') tensor(0.1798, device='cuda:0')
model.features.4.expand1x1.weight tensor(0.2144, device='cuda:0') tensor(0.4214, device='cuda:0')
model.features.4.expand1x1.bias tensor(0.4994, device='cuda:0') tensor(0.0958, device='cuda:0')
model.features.4.expand3x3.weight tensor(0.1063, device='cuda:0') tensor(0.2963, device='cuda:0')
model.features.4.expand3x3.bias tensor(0.0489, device='cuda:0') tensor(0.0719, device='cuda:0')
model.features.6.squeeze.weight tensor(0.1736, device='cuda:0') tensor(0.3544, device='cuda:0')
model.features.6.squeeze.bias tensor(0.2420, device='cuda:0') tensor(0.0896, device='cuda:0')
model.features.6.expand1x1.weight tensor(0.1211, device='cuda:0') tensor(0.2428, device='cuda:0')
model.features.6.expand1x1.bias tensor(0.0670, device='cuda:0') tensor(0.0162, device='cuda:0')
model.features.6.expand3x3.weight tensor(0.0593, device='cuda:0') tensor(0.1917, device='cuda:0')
model.features.6.expand3x3.bias tensor(0.0227, device='cuda:0') tensor(0.0160, device='cuda:0')
model.features.7.squeeze.weight tensor(0.1207, device='cuda:0') tensor(0.2179, device='cuda:0')
model.features.7.squeeze.bias tensor(0.1484, device='cuda:0') tensor(0.0381, device='cuda:0')
model.features.7.expand1x1.weight tensor(0.1235, device='cuda:0') tensor(0.2279, device='cuda:0')
model.features.7.expand1x1.bias tensor(0.0450, device='cuda:0') tensor(0.0100, device='cuda:0')
model.features.7.expand3x3.weight tensor(0.0609, device='cuda:0') tensor(0.1628, device='cuda:0')
model.features.7.expand3x3.bias tensor(0.0132, device='cuda:0') tensor(0.0079, device='cuda:0')
model.features.9.squeeze.weight tensor(0.1093, device='cuda:0') tensor(0.2459, device='cuda:0')
model.features.9.squeeze.bias tensor(0.0646, device='cuda:0') tensor(0.0135, device='cuda:0')
model.features.9.expand1x1.weight tensor(0.0840, device='cuda:0') tensor(0.1860, device='cuda:0')
model.features.9.expand1x1.bias tensor(0.0177, device='cuda:0') tensor(0.0033, device='cuda:0')
model.features.9.expand3x3.weight tensor(0.0476, device='cuda:0') tensor(0.1393, device='cuda:0')
model.features.9.expand3x3.bias tensor(0.0058, device='cuda:0') tensor(0.0030, device='cuda:0')
model.features.10.squeeze.weight tensor(0.0872, device='cuda:0') tensor(0.1676, device='cuda:0')
model.features.10.squeeze.bias tensor(0.0484, device='cuda:0') tensor(0.0088, device='cuda:0')
model.features.10.expand1x1.weight tensor(0.0859, device='cuda:0') tensor(0.2145, device='cuda:0')
model.features.10.expand1x1.bias tensor(0.0160, device='cuda:0') tensor(0.0025, device='cuda:0')
model.features.10.expand3x3.weight tensor(0.0456, device='cuda:0') tensor(0.1429, device='cuda:0')
model.features.10.expand3x3.bias tensor(0.0070, device='cuda:0') tensor(0.0021, device='cuda:0')
model.features.11.squeeze.weight tensor(0.0786, device='cuda:0') tensor(0.2003, device='cuda:0')
model.features.11.squeeze.bias tensor(0.0422, device='cuda:0') tensor(0.0069, device='cuda:0')
model.features.11.expand1x1.weight tensor(0.0690, device='cuda:0') tensor(0.1400, device='cuda:0')
model.features.11.expand1x1.bias tensor(0.0138, device='cuda:0') tensor(0.0022, device='cuda:0')
model.features.11.expand3x3.weight tensor(0.0366, device='cuda:0') tensor(0.1517, device='cuda:0')
model.features.11.expand3x3.bias tensor(0.0109, device='cuda:0') tensor(0.0023, device='cuda:0')
model.features.12.squeeze.weight tensor(0.0729, device='cuda:0') tensor(0.1736, device='cuda:0')
model.features.12.squeeze.bias tensor(0.0814, device='cuda:0') tensor(0.0084, device='cuda:0')
model.features.12.expand1x1.weight tensor(0.0977, device='cuda:0') tensor(0.1385, device='cuda:0')
model.features.12.expand1x1.bias tensor(0.0102, device='cuda:0') tensor(0.0032, device='cuda:0')
model.features.12.expand3x3.weight tensor(0.0365, device='cuda:0') tensor(0.1312, device='cuda:0')
model.features.12.expand3x3.bias tensor(0.0038, device='cuda:0') tensor(0.0026, device='cuda:0')
model.classifier.1.weight tensor(0.0285, device='cuda:0') tensor(0.0865, device='cuda:0')
model.classifier.1.bias tensor(0.0362, device='cuda:0') tensor(0.0192, device='cuda:0')

ipdb> opt.lr    # 查看学习率
0.001

ipdb> opt.lr = 0.002    # 更改学习率

ipdb> for p in optimizer.param_groups: \
    p['lr'] = opt.lr

ipdb> model.save()    # 保存模型
'checkpoints/squeezenet_20191004212249.pth'

ipdb> c    # 继续运行,直到第88行暂停
222it [16:38, 35.62s/it]> e:/Users/mac/Desktop/jupyter/mdFile/deeplearning/main.py(88)train()
     87             optimizer.zero_grad()
1--> 88             score = model(input)
     89             loss = criterion(score, target)

ipdb> s    # 进入model(input)内部,即model.__call__(input)
--Call--
> c:\programdata\anaconda3\lib\site-packages\torch\nn\modules\module.py(537)__call__()
    536 
--> 537     def __call__(self, *input, **kwargs):
    538         for hook in self._forward_pre_hooks.values():

ipdb> n    # 下一步
> c:\programdata\anaconda3\lib\site-packages\torch\nn\modules\module.py(538)__call__()
    537     def __call__(self, *input, **kwargs):
--> 538         for hook in self._forward_pre_hooks.values():
    539             result = hook(self, input)

ipdb> n    # 下一步
> c:\programdata\anaconda3\lib\site-packages\torch\nn\modules\module.py(544)__call__()
    543                 input = result
--> 544         if torch._C._get_tracing_state():
    545             result = self._slow_forward(*input, **kwargs)

ipdb> n    # 下一步
> c:\programdata\anaconda3\lib\site-packages\torch\nn\modules\module.py(547)__call__()
    546         else:
--> 547             result = self.forward(*input, **kwargs)
    548         for hook in self._forward_hooks.values():

ipdb> s    # 进入forward函数内容
--Call--
> c:\programdata\anaconda3\lib\site-packages\torch\nn\modules\loss.py(914)forward()
    913 
--> 914     def forward(self, input, target):
    915         return F.cross_entropy(input, target, weight=self.weight,

ipdb> input    # 查看input变量值
tensor([[4.5005, 2.0725],
        [3.5933, 7.8643],
        [2.9086, 3.4209],
        [2.7740, 4.4332],
        [6.0164, 2.3033],
        [5.2261, 3.2189],
        [2.6529, 2.0749],
        [6.3259, 2.2383],
        [3.0629, 3.4832],
        [2.7008, 8.2818],
        [5.5684, 2.1567],
        [3.0689, 6.1022],
        [3.4848, 5.3831],
        [1.7920, 5.7709],
        [6.5032, 2.8080],
        [2.3071, 5.2417],
        [3.7474, 5.0263],
        [4.3682, 3.6707],
        [2.2196, 6.9298],
        [5.2201, 2.3034],
        [6.4315, 1.4970],
        [3.4684, 4.0371],
        [3.9620, 1.7629],
        [1.7069, 7.8898],
        [3.0462, 1.6505],
        [2.4081, 6.4456],
        [2.1932, 7.4614],
        [2.3405, 2.7603],
        [1.9478, 8.4156],
        [2.7935, 7.8331],
        [1.8898, 3.8836],
        [3.3008, 1.6832]], device='cuda:0', grad_fn=<AsStridedBackward>)

ipdb> input.data.mean()    # 查看input的均值和标准差
tensor(3.9630, device='cuda:0')
ipdb> input.data.std()
tensor(1.9513, device='cuda:0')

ipdb> u    # 跳回上一层
> c:\programdata\anaconda3\lib\site-packages\torch\nn\modules\module.py(547)__call__()
    546         else:
--> 547             result = self.forward(*input, **kwargs)
    548         for hook in self._forward_hooks.values():

ipdb> u    # 跳回上一层
> e:/Users/mac/Desktop/jupyter/mdFile/deeplearning/main.py(88)train()
     87             optimizer.zero_grad()
1--> 88             score = model(input)
     89             loss = criterion(score, target)

ipdb> clear    # 清除所有断点
Clear all breaks? y
Deleted breakpoint 1 at e:/Users/mac/Desktop/jupyter/mdFile/deeplearning/main.py:88

ipdb> c    # 继续运行,记得先删除"debug/debug.txt",否则很快又会进入调试模式
59it [06:21,  5.75it/s]loss: 0.24856307208538073
76it [06:24,  5.91it/s]

当我们想要进入 debug 模式,修改程序中某些参数值或者想分析程序时,就可以通过创建 debug 标识文件,此时程序会进入调试模式,调试完成之后删除这个文件并在 ipdb 调试接口输入 c 继续运行程序。如果想退出程序,也可以使用这种方式,先创建 debug 标识文件,然后输入 quit 在退出 debug 的同时退出程序。这种退出程序的方式,与使用 Ctrl + C 的方式相比更安全,因为这能保证数据加载的多进程程序也能正确地退出,并释放内存、显存等资源。

PyTorch 和 ipdb 集合能完成很多其他框架所不能完成或很难完成的功能。根据笔者日常使用的总结,主要有以下几个部分:

  1. 通过 debug 暂停程序。当程序进入 debug 模式后,将不再执行 CPU 和 GPU 运算,但是内存和显存及相应的堆栈空间不会释放。
  2. 通过 debug 分析程序,查看每个层的输出,查看网络的参数情况。通过 u(p) 、 d(own) 、 s(tep) 等命令,能够进入指定的代码,通过 n(ext) 可以单步执行,从而看到每一层的运算结果,便于分析网络的数值分布等信息。
  3. 作为动态图框架, PyTorch 拥有 Python 动态语言解释执行的优点,我们能够在运行程序时,用过 ipdb 修改某些变量的值或属性,这些修改能够立即生效。例如可以在训练开始不久根据损失函数调整学习率,不必重启程序。
  4. 如果在 IPython 中通过 %run 魔法方法运行程序,那么在程序异常退出时,可以使用 %debug 命令,直接进入 debug 模式,通过 u(p) 和 d(own) 跳到报错的地方,查看对应的变量,找出原因后修改相应的代码即可。有时我们的模式训练了好几个小时,却在将要保存模式之前,因为一个小小的拼写错误异常退出。此时,如果修改错误再重新运行程序又要花费好几个小时,太浪费时间。因此最好的方法就是看利用 %debug 进入调试模式,在调试模式中直接运行 model . save() 保存模型。在 IPython 中, %pdb 魔术方法能够使得程序出现问题后,不用手动输入 %debug 而自动进入 debug 模式,建议使用。

四、 通过PyTorch实现项目中容易遇到的问题

PyTorch 调用 CuDNN 报错时,报错信息诸如 CUDNN_STATUS_BAD_PARAM,从这些报错内容很难得到有用的帮助信息,最后先利用 PCU 运行代码,此时一般会得到相对友好的报错信息,例如在 ipdb 中执行 model.cpu() (input.cpu()), PyTorch 底层的 TH 库会给出相对比较详细的信息。

常见的错误主要有以下几种:

  • 类型不匹配问题。例如 CrossEntropyLoss 的输入 target 应该是一个 LongTensor ,而很多人输入 FloatTensor 。
  • 部分数据忘记从 CPU 转移到 GPU 。例如,当 model 存放于 GPU 时,输入 input 也需要转移到 GPU 才能输入到 model 中。还有可能就是把多个 model 存放于一个 list 对象,而在执行 model.cuda() 时,这个 list 中的对象是不会被转移到 CUDA 上的,正确的用法是用 ModuleList 代替。
  • Tensor 形状不匹配。此类问题一般是输入数据形状不对,或是网络结构设计有问题,一般通过 u(p) 跳到指定代码,查看输入和模型参数的形状即可得知。

此外,可能还会经常遇到程序正常运行、没有报错,但是模型无法收敛的问题。例如对于二分类问题,交叉熵损失一直徘徊在 0.69 附近(ln2),或者是数值出现溢出等问题,此时可以进入 debug 模式,用单步执行查看,每一层输出的均值和方差,观察从哪一层的输出开始出现数值异常。还要查看每个参数梯度的均值和方差,查看是否出现梯度消失或者梯度爆炸等问题。一般来说,通过再激活函数之前增加 BatchNorm 层、合理的参数初始化、使用 Adam 优化器、学习率设为0.001,基本就能确保模型在一定程度收敛。

五、总结

本章带同学们从头实现了一个 Kaggle 上的经典竞赛,重点讲解了如何合理地组合安排程序,同时介绍了一些在PyTorch中调试的技巧,下章将正式的进入编程实战之旅,其中一些细节不会再讲的如此详细,做好心理准备。

以上就是PyTorch的Debug指南的详细内容,更多关于PyTorch Debug的资料请关注三水点靠木其它相关文章!

Python 相关文章推荐
wxPython定时器wx.Timer简单应用实例
Jun 03 Python
Python编程django实现同一个ip十分钟内只能注册一次
Nov 03 Python
python3解析库pyquery的深入讲解
Jun 26 Python
Sanic框架安装与简单入门示例
Jul 16 Python
使用python3调用wxpy模块监控linux日志并定时发送消息给群组或好友
Jun 05 Python
Django 模型类(models.py)的定义详解
Jul 19 Python
python 实现二维列表转置
Dec 02 Python
Python Sympy计算梯度、散度和旋度的实例
Dec 06 Python
django框架中ajax的使用及避开CSRF 验证的方式详解
Dec 11 Python
python opencv实现信用卡的数字识别
Jan 12 Python
python str字符串转uuid实例
Mar 03 Python
python list等分并从等分的子集中随机选取一个数
Nov 16 Python
基于Python的EasyGUI学习实践
Python列表删除重复元素与图像相似度判断及删除实例代码
使用python如何删除同一文件夹下相似的图片
May 07 #Python
python学习之panda数据分析核心支持库
Python基于Tkinter开发一个爬取B站直播弹幕的工具
May 06 #Python
Python爬虫之爬取最新更新的小说网站
May 06 #Python
Python基础之操作MySQL数据库
You might like
phpmail类发送邮件函数代码
2012/02/20 PHP
php删除与复制文件夹及其文件夹下所有文件的实现代码
2013/01/23 PHP
用Php编写注册后Email激活验证的实例代码
2013/03/11 PHP
CodeIgniter框架数据库事务处理的设计缺陷和解决方案
2014/07/25 PHP
php使用指定编码导出mysql数据到csv文件的方法
2015/03/31 PHP
PHPStrom中实用的功能和快捷键大全
2015/09/23 PHP
php读取本地json文件的实例
2018/03/07 PHP
jquery tab标签页的制作
2010/05/10 Javascript
javascript instanceof 内部机制探析
2010/10/15 Javascript
jquery each()源代码
2011/02/14 Javascript
Javascript 面向对象编程(coolshell)
2012/03/18 Javascript
JS限制上传图片大小不使用控件在本地实现
2012/12/19 Javascript
我的Node.js学习之路(一)
2014/07/06 Javascript
jQuery中attr()与prop()函数用法实例详解(附用法区别)
2015/12/29 Javascript
AngularJS 指令的交互详解及实例代码
2016/09/14 Javascript
解决vue2.0动态绑定图片src属性值初始化时报错的问题
2018/03/14 Javascript
VUE简单的定时器实时刷新的实现方法
2019/01/20 Javascript
vue-cli随机生成port源码的方法
2019/09/02 Javascript
vue-calendar-component 封装多日期选择组件的实例代码
2020/12/04 Vue.js
[03:10]2014DOTA2 TI马来劲旅Titan首战告捷目标只是8强
2014/07/10 DOTA
详解C++编程中一元运算符的重载
2016/01/19 Python
python学习笔记之列表(list)与元组(tuple)详解
2017/11/23 Python
快速了解Python相对导入
2018/01/12 Python
python使用response.read()接收json数据的实例
2018/12/19 Python
python ddt数据驱动最简实例代码
2019/02/22 Python
Python基础之字典常见操作经典实例详解
2020/02/26 Python
Python单例模式的四种创建方式实例解析
2020/03/04 Python
python和C++共享内存传输图像的示例
2020/10/27 Python
Python爬虫中Selenium实现文件上传
2020/12/04 Python
Python3 用matplotlib绘制sigmoid函数的案例
2020/12/11 Python
Paul Smith英国官网:英国国宝级时装品牌
2019/03/21 全球购物
巴西独家产品和现场演示购物网站:Shoptime
2019/07/11 全球购物
小学班主任评语
2014/12/29 职场文书
如何在centos上使用yum安装rabbitmq-server
2021/03/31 Servers
MySQL Router实现MySQL的读写分离的方法
2021/05/27 MySQL
JavaScript数组 几个常用方法总结
2021/11/11 Javascript