JincResize 代码重构(1)

前一篇 Blog 记录了将原作者代码(初步)从 C 语言迁移到 C++ 上的过程,以及添加 8bit 输入支持的方法。下一步我想模仿 HomeOfVapourSynthEvolution 的代码风格,对上述代码进行重构。同时,也期待能发现,我先前修改的代码运行缓慢的原因。

Blog 写得详细一些,这也是我学习 C++ 基础知识的过程。

未完成的 C 语言到 C++ 迁移

其实 C 语言和 C++ 的区别还有很多。

C++ 的类型转换

编写 VapourSynth 插件,在四个VS__CC函数的开头,都需要进行类型转换,原始代码使用 C 语言风格的类型转换,类似下面这样。

1
FilterData* d = (FilterData*)* instanceData;

如果让代码“更 C++”一些,则使用类型转换运算符(Type Conversion Operator)。

1
FilterData* d = static_cast<FilterData*>(*instanceData);

对于 filterInit ()filterGetFrame()filterFree()三个函数,使用static_cast进行转换。static_cast用于相关类型的指针之间的转换。

对于filterCreate()函数,使用reinterpret_cast进行类型转换。无论类型是否相关,reinterpret_cast均可进行(强制)类型转换,是最接近 C 语言风格的类型转换运算符。

However,上述改动对运行速度无可见影响(想一下也知道,使用类型转换运算符或许可以让代码更安全,但并没有涉及速度的问题)。

从指针到智能指针

书中的描述,给我的印象就是两句话:一是智能指针不是指针,是类,包含重载运算符的类(好像很多地方都这么说,用 A 修饰的 B 不是 B,JavaScript 不是 Java);二是智能指针要配合解除引用运算符*和成员选择运算符->使用,实现类似指针的效果(不能用.)。

顺带一提,使用智能指针需要#include <memory>

智能指针的优势,就是能及时、更安全地管理内存。

在原始代码的fiterCreate()函数部分,首先声明了FilterData类型的变量d和指针data(虽然我从头看到尾,也没明白指针data干了什么),在函数的最后,使用malloc()函数为指针data分配了空间,并赋值指向d

仿照HomeOfVapourSynthEvolution的代码风格,使用智能指针代替上述普通指针,就是将开头的声明由

1
2
FilterData d;
FilterData* data;

改为

1
std::unique_ptr<FilterData> d = std::make_unique<FilterData>();

以及将使用.的地方改为->(为什么原代码要用.呢)。

此外,还有一个事情,在函数体的最后,vsapi->ceateFiler()那个部分,使用release()函数将d释放掉…我还不知道这是否是配合智能指针的写法,还是仅仅是一个普通的内存释放。

从智能指针到多态

《21 天学通 C++》中提到,智能指针在处理多态对象时具有优势。于是就翻到了多态的那一章,看到了虚函数相关的内容,便想起了在读 avs 版 JincResize 代码时,看到虚函数便一头雾水。

从宏定义到 std

原始代码使用了两个自定义的宏函数,MAXMIN。先前已经将一部分MAXMINstd::maxstd::min替代,但另一些无法替代。今天才发现,std::maxstd::min的输入变量类型需要一致,自定义的宏函数则没有做规定(这应该也反映了宏的不安全)。那些不能替代的表达式,均是浮点型和整型混搭,改成一致后就可以用std::maxstd::min了。

比较了一下宏、std、使用了模板的内联函数,三者的效率,发现还是使用std更快一点。(测试方法,测试了一段1200帧左右的短视频,比较时间)

内存管理相关

其实上面的智能指针就是关于内存管理的。这里单独拿出来,主要是想说一下newdeletemalloc()free()。这也是我理解最困难的地方。

一方面,从一开始迁移代码就不明白的data = malloc(sizeof(d));。另一方面,读HomeOfVapourSynthEvolution的代码,像在filterFree()中,只对d进行了delete,而在原始代码中,使用了free(),不仅释放了d,还释放了d->lut,我不明白为什么还要单独释放lut这么一个对象,明明整个d都释放了。同时,我还不知道deletefree()两种操作的区别(虽然我知道free()malloc()配合使用,但我好像找不到相应的malloc()啊)。

在前辈的指点下,阅读了这篇教程( https://www.includehelp.com/cpp-tutorial/difference-between-delete-and-free.aspx ),至少是知道了 deletefree()更快,因为前者是操作符,后者是函数。

其他一些细节

不是很关键的知识,还是记下来,免得忘了。

对于cmath找不到 π 的宏定义M_PI,可以在预处理中加入_USE_MATH_DEFINES

增加异常处理

这部分语法比较简单,在filterCreate()函数中增加try {...} catch {...}代码块,并把之前的内容 copy 到try {...}内就 OK 了。需要思考的是会遇到什么异常。

真正意义上的重构部分

引子

在前一节的内存管理中也提到,我一直不太明白原始代码很多关于对象的“单独操作”的意义。除了上面的free(d->lut)外,类似new_vi.width = d->w等单独操作,也不太明白。话说回来,这两个例子是有区别的:后者删了的话,编译通过,但在 vs 中测试程序就崩溃;前者删不删都完全正常。

循环内计算与循环外调用

除法很慢,而原始代码中又存在可以移除 for 循环的除法计算,所以移除 for 循环,希望可以提升速度。

原代码

1
2
3
4
5
6
7
for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
double rpm_x = (x + 0.5) * iw / ow;
double rpm_y = (y + 0.5) * ih / oh;
...
}
}

新代码

1
2
3
4
5
6
7
8
9
10
double scale_x = (double)iw / ow;
double scale_y = (double)ih / oh;

for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
double rpm_x = (x + 0.5) * scale_x;
double rpm_y = (y + 0.5) * scale_y;
...
}
}

在实际使用中,ohow至少对应 1080p or 720p。对于 1080p,上述双重 for 循环就要循环 1920*1080 ≈ 2*10^6 次,即 200+ 万次,除法进行了 400+ 万次。我以为把除法放到循环外,能提高效率,没想到用上面的视频片段测试,反而更慢了…

记得在一个地方读到过,调用循环外的变量也存在开销,因外定义、调用变量就要调用析构函数(但我又看到过对于简单变量不需要析构函数…),进而降低效率。所以存在“循环内计算vs循环外调用”的取舍。

emmm我不知道该怎么解释了,也可能是我的测试方法依赖于当时 CPU 的表现?

除了这个除法的改动,我还试着把 for 循环内的位移运算拿出来。但大家都知道,位移运输很快,所以拿到 for 循环外后,速度下降得更明显了…我好难…

知乎上看到两句话,合起来就是,“不必追求 5% 效率的提升”,“这样可能会降低其他方面的性能,如可扩展性”。

虽然目前的改动,对效率的影响可能连 5% 都不到,但我还是要改的吧,毕竟上面的双重 for 循环内还有一个双重 for 循环…

目前代码的运算量

核心运算量是前前后后加起来的五重 for 循环,第一重循环 3 次,这倒是没什么,后面两个双重for循环,对于放大到 1080p 的情况,大约要执行 4.3*10^12 次,即 4.3 万亿次…乘 3,就是 12.9 万亿次…除了 for 循环本身的判断,循环内还有 if 判断语句(似乎这个也会拖慢性能)。

虽然作为放大算法,这恐怕也没办法..但看上去仍觉得可怕…

从 avs 版代码得到的启发

去看了 avs 版 JincResize 代码,原始版的核心代码和我现在的代码看上去差不多。但最新的代码,在第二个双重 for 循环中,省去了除法运算,也就是省去了归一化过程。怎么做到这一点的,我还没看明白,但这应该是一个优化的方向。

功能增加

添加 32bit 支持(2019.12.18)

在完成从 C 到 C++ 的迁移后,只支持 8-16bit 整型输入,若要增加 32bit 浮点数的支持,首先要修改一下模板函数的调用,再用float型调用一次。

1
2
3
4
5
6
if (d->vi->format->bytesPerSample == 1)
process<uint8_t>();
else if (d->vi->format->bytesPerSample == 2)
process<uint16_t>();
else
process<float>();

(这样看使用模板的理由就更充分了,如果说uint8_tuint16_t都是整型,(或许?)可以用直接用int型来操作,那么这次引入float,模板的作用就更明显了。)

当然,仅仅接口处改了还不行,去看一下实际处理部分,也就是运算过程中有无涉及位深的部分。

唯一涉及的就是这句话

1
pixel = std::max(std::min(pixel / normalizer, (1 << d->vi->format->bitsPerSample) - 1.0), 0.0);

这种比较两次大小的操作在图像处理中很常见,用最大值和最小值限制,得到一个中间值的值,以防结果“溢出”色彩取值范围。对于整型而言,限制无非就是色彩[0,2^depth-1],比如熟悉的8bit,色彩取值[0,255],16bit 是[0,65335]。而对于 32bit 浮点型,取值范围则是[-1.0,1.0],这样更好比较了,先判断一下stInteger(vs中判断是否为整型的操作),若否,则

1
pixel = std::max(std::min(pixel / normalizer, -1.0), 1.0);

至此,运算部分对 32bit 浮点型的支持也完成了。

其他

使用 gcc 编译

主要参考了这篇文章: https://www.qiufengblog.com/articles/gcc-dll.html

使用 gcc 编译 C 语言代码,编译为 dll。

1
gcc -o func.dll -shared main.c

配置头文件和库文件路径我还不会..只好先到这,还是用 VS 吧。

(2019.11.25)成功使用 gcc/g++ 进行编译,并且编译后的 dll 运行速度大幅提升,和原作者 dll 的速度相似。

关于测试

借助 x265 编码器进行的测试,由于增加了视频重编码的过程,所以其他因素影响可能较大。

测试时有点意外的就是,我上面的所以改动(“从宏定义到 std”部分和“循环内计算与循环外调用”部分),最后重编码得到的文件,hash 值都一样。所以这些改动对运行结果没有影响,倒也放心了(特别把除法移除 for 的部分,我以为新声明一个变量储存除法结果,可能导致精度下降,但其并没有)。

下一步的计划

总之,现在就是从 free 和 delete 入手,开始代码重构;同时看多态的部分,争取迁移 avs 版的 jinc 函数实现。