这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/SE2020 |
---|---|
这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/SE2020/homework/11167 |
这个作业的目标 | 大数据处理,json解析,文件io |
学号 | 031802504 |
PSP 表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 10 | 5 |
Estimate | 估计这个任务需要多少时间 | 30 | 20 |
Development | 开发 | ||
Analysis | 需求分析 (包括学习新技术) | 60 | 70 |
Design Spec | 生成设计文档 | 15 | 15 |
Design Review | 设计复审 | 20 | 30 |
Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 30 | 10 |
Design | 具体设计 | 30 | 20 |
Coding | 具体编码 | 360 | 480 |
Code Review | 代码复审 | 60 | 90 |
Test | 测试(自我测试,修改代码,提交修改) | 120 | 180 |
Reporting | 报告 | ||
Test Report | 测试报告 | ||
Size Measurement | 计算工作量 | 30 | 30 |
Postmortem & Process Improvement Plan | 事后总结, 并提出过程改进计划 | 60 | 40 |
合计 | 825 | 990 |
解题思路
- 先看作业描述,下载示例数据
- 根据作业要求fork项目代码并clone到本地
- 阅读github actions相关文档,了解评测步骤
- 认真看一遍参考程序,理解操作
- 学习github的commit规范
- 参照已有代码规范编写本项目的codestyle.md
- 编写程序
- 编写单元测试
- 搜索学习cProfile和Coverage
- 优化程序
- 写完博客并提交作业
设计实现/迭代过程
优化的参考程序
参考程序主要有两点影响了运行速度,一个是一次读出整个文件,另一个是对字典的递归解嵌套。
初版据此优化了变量命名、函数逻辑、文件读取等,得到了较快的速度(25M 0.9s)。
进一步优化
通过性能测试发现耗时严重的主要是json的解析
因此读原始文件时逐行使用正则表达式匹配得到所需的信息三元组
计数完成后再通过pickle库写入结果文件
-
pandas
一开始试图用pandas读取json,然而查了资料发现偏离了它原本的用途,改用正则表达式 -
正则变慢
EVENTS = ("PushEvent", "IssueCommentEvent", "IssuesEvent", "PullRequestEvent", )
r'.*?((?:%s)Event).*?actor.*?"login":"(\S+?)".*?repo.*?"name":"(\S+?)"' % '|'.join(EVENTS)
这个正则匹配速度非常慢
运行速度提升近一倍
协程
async with aiofiles.open(filename, 'r', encoding='utf-8') as f:
async for line in f:
res = pattern.search(line)
...
考虑到文件io仍是性能瓶颈,于是想并发读取文件并处理数据,而众所周知python由于GIL的存在多线程表现并不好,于是想借助asyncio模块进行异步读取。看了python官方文档及博客等很多资料都没有找到相关用法,最多只有网络io。最后在Stack Overflow上看到有人提及aifiles库,配合asyncio试了一下发现速度反而慢了几倍,大概是由于它是多线程并非真正的协程,而且异步的readline造成了线程锁的不断切换,或许改成一次性全部读出并配合json.load效果会更好?
多进程
pool = Pool(processes=cpu_count())
for cur_dir, sub_dir, filenames in os.walk(dir_path):
for name in filenames:
pool.apply_async(self.parse_events, args=(f'{cur_dir}/{name}', u_e, r_e, ur_e))
...
既然协程行不通,那么多进程总可以了吧。
用到的是python的标准库multiprocessing ,利用其中的Pool根据cpu数构建一个进程池,将对文件的读取处理抽象成一个函数,递归查找目录下json文件并将文件名作为参数通过线程池调用处理函数。
马上就遇到了一个棘手的问题,各进程之间无法直接交换数据。查看了文档后发现了管道Pipe通信,但send与recv的时机很难把握,尝试后发现了奇怪的结果,大概确实只能用于两个进程。
后来尝试了管理器Manager,将那三个事件字典换成manager.dict并作为参数传入,预期是各个能够共同修改它,然而一直有问题。后来发现是manager的字典不能检测到深层的修改,于是用了个三中间变量,最后这个特殊的dict还得转换成dict保存才总算跑通,比协程又更加慢了,144M数据花了17秒,大部分时间都在切换进程,剩下的时间又有一大部分花在了进程通信,或许采用官方不推荐的内存变量共享效果会好一些。
多线程
结果最后还是回到了多线程,毕竟理论上即便有锁,对于io应该也是有提升的。
利用concurrent.futures.ThreadPoolExecutor构建了线程池,与多进程时一样将文件名传给各个线程处理,此时不需要考虑变量问题,代码得以简化很多。
将近600M数据的测试默认的40大小线程池需要花费3.5s初始化,简直是重大突破,
但如果线程池大小为1则只需要3s,问题还是出在了线程的频繁切换,大概让线程一次读出整个文件会好些,但岂不是又有可能内存不足...主要还是没有真正的数据测试很难决定。
关键函数流程图(analyse)
代码说明
- Run.analyse
def analyse(self):
args = self.parser.parse_args()
event, user, repo = args.event, args.user, args.repo
if args.init:
self.data.init(args.init)
return 'init done'
self.data.load()
if not event:
raise RuntimeError('error: the following arguments are required: -e/--event')
if not user and not repo:
raise RuntimeError('error: the following arguments are required: -u/--user or -r/--repo')
if user and repo:
res = self.data.user_repo_events.get(user, {}).get(repo, {}).get(event, 0)
elif user:
res = self.data.user_events.get(user, {}).get(event, 0)
else:
res = self.data.repo_events.get(repo, {}).get(event, 0)
return res
根据参数决定初始化或者取数据
- Data.init
def init(self, dir_path: str):
pool = ThreadPoolExecutor()
for cur_dir, sub_dir, filenames in os.walk(dir_path):
filenames = filter(lambda r: r.endswith('.json'), filenames)
for name in filenames:
pool.submit(self.__count_events, f'{cur_dir}/{name}')
pool.shutdown()
with open('0.pkl', 'wb') as f:
pickle.dump(self.user_events, f)
...
创建线程池,递归目录,筛选数据文件,线程池调用计数函数完成数据处理,最后将处理好的三个字典序列化写入文件
- Data.__count_events
def __count_events(self, filename: str):
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
res = pattern.search(line)
if res is None or res[1] not in EVENTS:
continue
event, user, repo = res.groups()
self.user_events.setdefault(user, {})
self.user_repo_events.setdefault(user, {})
self.repo_events.setdefault(repo, {})
self.user_repo_events[user].setdefault(repo, {})
self.user_events[user][event] = self.user_events[user].get(event, 0)+1
self.repo_events[repo][event] = self.repo_events[repo].get(event, 0)+1
self.user_repo_events[user][repo][event] = self.user_repo_events[user][repo].get(event, 0)+1
打开json文件中逐行使用正则表达式匹配信息,抽取所需三元组(event, user, repo),并存入字典
单元测试
截图
描述
- 对Data类测试初始化(数据组成为4份2020-01-01-15.json + 1份2015-01-01-15.json),借助path.exists断言文件成功生成
- 三种情形下事件的数量,通过与参考程序运行结果对比来验证正确性
- 此处因为同文件有四份,因此事件数也对应*4,结果正确
单元测试覆盖率
因为此处没有测试需要命令行参数的Run类,因此覆盖率仅有67%
性能优化和性能测试
多线程
注释掉线程池(单进程单线程)
总结
- 以上测试均为初始化(--init)测试,实际的运行(-e/-u/-r)约0.1s
- 数据为4份2020-01-01-15.json + 1份2015-01-01-15.json,共589M
- 就目前来看线程越多效率越低,差距明显(约0.5s),令人失望
代码规范链接
https://github.com/Stareven233/2020-personal-python/blob/master/codestyle.md
总结
- python的文件读取各种花样(协程/多进程/多线程)终究还是比不上淳朴的readline
- json解析很花时间,但使用正则表达式也不是越长越好
- 对于任务应该不管好坏先做出来再慢慢优化,不然最后可能会来不及