Tags for the past Two years

tags.png

定位 C++/CLI 库的加载失败异常

程序在加载 C++/CLI 动态库的时候,出现 FileLoadException,常见错误是其中某些依赖没有找到,这个时候异常信息应该是类似:一个xxx的依赖没有找到;这个问题很好解决,确定缺少的依赖补充上就可以了,可以使用 dumpbin 工具查看相关的依赖:

1
dumpbin /dependents target.dll

但是,有一种比较隐晦的错误导致动态库加载失败,错误提示:未能加载由xxx.dll导入的过程,除了这些,几乎没有什么有用的错误信息让我们来确定到底出现了什么问题。一般来说,按照目前我遇到的问题,这个一般都是对应的库文件调用了当前平台不支持的api导致的。当然,这个问题不易在开发的机器上发现,因为能调用到这个api就代表调试机器上有对应平台版本的SDK,一般如果前期没有发现到这个问题,那么问题被发现时肯定是在生成环境,所以需要开发人员注意调用接口的支持平台。

要定位是什么接口导致的问题,需要借助其他的调试工具来排查。微软的 Windows SDK 提供了对应的工具 gflags.exe,借助这个工具,我们可以在调试的过程中看到详细的动态库加载信息,在这些信息中可以定位到底是什么接口导致库文件加载失败。当然,你需要在出现问题的机器上使用这个工具进行操作,并通过 Visual Studio 进行远程调试。

gflags.png

如上图显示,通过输入你程序的名字和开启 Show loader snaps 开关,应用后使用 Visual Studio 进行调试,可以在输出窗口看到详细的加载信息:

locate_error.png

JIT Loop Invariant Hoisting

在上一篇文章中,我们已经对 Loop Invariant 概念有一个简单的了解,在文章的最后提到的 Loop Invariant 将在这篇文章做一个简单的介绍。

在我们开始之前

如果你了解 C# 程序的运行,应该知道 C# 代码先被编译器编译为 MSIL 中间代码,在实际运行的时候才通过 JIT 编译器将 MSIL 代码编译成机器代码并运行。因此,编译器有两个时段可以对代码进行优化:

  • C# -> MSIL
  • MSIL -> MACHINE CODE

但是,C#到MSIL的优化有限,大部分优化都是通过 JIT 来完成的。而这篇文章也是围绕微软最新的 RyuJIT 编译器来展开,其可能与旧的 JIT有所区别,但是应该差异不大。

Loop Invariant

首先看一下微软对 Loop Invariant Code Hoisting 的说明:

This phase traverses all the loop nests, in outer-to-inner order (thus hoisting expressions outside the largest loop in which they are invariant). It traverses all of the statements in the blocks in the loop that are always executed. If the statement is:

  • A valid CSE candidate
  • Has no side-effects
  • Does not raise an exception OR occurs in the loop prior to any side-effects
  • Has a valid value number, and it is a lclVar defined outside the loop, or its children (the value numbers from which it was computed) are invariant.

JIT 从外到内遍历所有的循环嵌套。它遍历循环嵌套结构中始终会被运行的BasicBlock(这是JIT里的类型,这里暂不展开,你可以暂时将其理解为编译器的基本类型)里的语句,并且这些语句符合以下条件:

  • 是一个 CSE
  • 没有副作用
  • 不会触发异常或者发生在任何副作用之前
  • 拥有一个可用的数字,这个数字定义在循环嵌套外或者其子元素是不可变的

上面提到的始终会被运行的循环嵌套(loop that are always executed), 如何理解? 嗯…,简单来讲,我们知道的循环结构,例如大部分编程语言都会有的 for loop 和 do while loop,这两种循环嵌套中,do while loop 就符合一定会被执行的循环嵌套,因为循环判断在一次循环后才会执行,而有些for loop 也可以被转换为 do while loop, JIT 会判断这个loop能否转换为do while loop后才进行后续的 loop hoisting 操作。

CSE - Common Subexpression Elimination

Utilizes value numbers to identify redundant computations, which are then evaluated to a new temp lvlVar, and then reused.

利用数字来识别(替代?)多余的计算,然后将它们计算为新的临时值,并重复利用。就如上篇文章中for循环中的 x+y
观察RyuJIT 中用于判定CSE的函数片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*****************************************************************************
*
* The following determines whether the given expression is a worthy CSE
* candidate.
*/
bool Compiler::optIsCSEcandidate(GenTreePtr tree)
{
/* No good if the expression contains side effects or if it was marked as DONT CSE */
if (tree->gtFlags & (GTF_ASG|GTF_DONT_CSE))
{
return false;
}
/* The only reason a TYP_STRUCT tree might occur is as an argument to
GT_ADDR. It will never be actually materialized. So ignore them.
Also TYP_VOIDs */
var_types type = tree->TypeGet();
genTreeOps oper = tree->OperGet();
if (type == TYP_STRUCT || type == TYP_VOID)
return false;
...
#ifdef _TARGET_X86_
if (type == TYP_FLOAT)
{
// TODO-X86-CQ: Revisit this
// Don't CSE a TYP_FLOAT on x86 as we currently can only enregister doubles
return false;
}
#else
if (oper == GT_CNS_DBL)
{
// TODO-CQ: Revisit this
// Don't try to CSE a GT_CNS_DBL as they can represent both float and doubles
return false;
}
#endif
...
/* Check for some special cases */
switch (oper)
{
...
case GT_LCL_VAR:
return false; // Can't CSE a volatile LCL_VAR
...
}
...
}

当然,这个函数还有许多细节,这里只挑选几个典型的片段。看代码可以知道:

  • 可以明确指定不能使用 CSE
  • 不能有赋值表达式
  • 在x86下 float 不能作为 CSE, x64 下不支持 float 和 double
  • 不支持 struct 和 void
  • 如果是可变 (volatile) 的变量,也不能作为CSE,这在之前的一篇文章中有对 volatile 的说明

Loop-Hoisting 还有颇多细节这里并没有覆盖,有兴趣可以去看源码:https://github.com/dotnet/coreclr/blob/master/src/jit/optimizer.cpp

Loop Hoisting

上篇文章中,提到 Loop Hoisting ,这是一个常见的编译器优化项。我们总是能通过汇编代码等低级语言来“窥探”代码实际是怎么“指示”硬件运行的(这边文章不会涉及到详细的汇编内容,但是会用C#反编译后得到的汇编代码来辅助说明)。如果你看过我前面的几篇文章,会发现我用了大量反编译后的汇编代码来辅助说明,毕竟,千言不如实际的“证据”有说服力。


言归正传,Loop Hoisting,循环提升(粗略的翻译),编译器对循环代码中 loop-invariant 的代码提取出循环体外,防止循环结构内CPU对主存的重复读取。这很好理解,减少 CPU 与主存之间的 IO 次数,能有效提升程序的运行效率。观察下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace loop_hoisting
{
class Program
{
static void Main(string[] args)
{
int[] array = new int[] { 1, 2, 3 };
int x = 10;
int y = 11;
LoopHoistTest(array, x, y);
}
static void LoopHoistTest(int[] array, int x, int y)
{
for (int i = 0; i < array.Length; i++)
{
array[i] = x + y;
}
}
}
}

很简单的一个例子,遍历列表且赋值。LoopHoistTest 函数的循环判断里,直接读取列表的长度,编译器在碰到这种情况,会对其进行优化,将对列表长度的读取进行提升(Hoist),在循环体入口处缓存列表长度,并以此为判断依据,也就是说,从汇编代码的角度,循环判断始终去寄存器中读取缓存的列表长度信息,而不是每次都到主存中读取,以此来提到运行效率。另外,x+y很明显也是一段 loop-invariant 代码,相似地,编译器会将 x+y 的值缓存在某个通用寄存器内,并以此做赋值运算。编译器优化后的代码,就相当于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace loop_hoisting
{
class Program
{
static void Main(string[] args)
{
int[] array = new int[] { 1, 2, 3 };
int x = 10;
int y = 11;
LoopHoistTest(array, x, y);
}
static void LoopHoistTest(int[] array, int x, int y)
{
int length = array.Length;
int sum = x+y;
for (int i = 0; i < length; i++)
{
array[i] = sum;
}
}
}
}

观察汇编代码:
optLoopHoisting_opted.png
第一个红色框选的汇编代码:

1
mov ebx,dword ptr [rsi+8] //将rsi寄存器值加上8的偏移量指向的主存中的值复制到ebx通用寄存器

其中 rsi 寄存器中的值就是主存中 array 的地址,偏移的8位指向 length 字段,这段指令将数值中的长度信息储存在 ebx 通用寄存器中,并且在以后的 cmp 指令中使用,而不是直接与主存中的内容比较。

1
lea ebp,[rdx+r8] //将 rdx 和 r8 寄存器中的值相加并传送到 ebp 寄存器

其中,rdxr8 寄存器分别储存着 x 和 y 的值,两者的和被储存在 ebp 寄存器,以后的指令都使用这个寄存器中的值,不再重复计算。

当然,并不是所有的循环代码都可以被优化,这涉及到 Loop-invariant 条件的判定,我们下篇文章再讲。

C# 内存模型

在 C# 的语言规范中 ECMA-334,对于Volatile关键字的描述:

15.5.4 Volatile fields
When a field-declaration includes a volatile modifier, the fields introduced by that declaration are volatile fields. For non-volatile fields, optimization techniques that reorder instructions can lead to unexpected and unpredictable results in multi-threaded programs that access fields without synchronization such as that provided by the lock-statement (§13.13). These optimizations can be
performed by the compiler, by the run-time system, or by hardware. For volatile fields, such reordering optimizations are restricted:

  • A read of a volatile field is called a volatile read. A volatile read has “acquire semantics”; that is, it is guaranteed to occur prior to any references to memory that occur after it in the instruction sequence.
  • A write of a volatile field is called a volatile write. A volatile write has “release semantics”; that is, it is guaranteed to happen after any memory references prior to the write instruction in the instruction sequence.

简单来说,对于常规字段,由于代码优化而导致指令顺序改变,如果没有进行一定的同步控制,在多线程应用中可能会导致意想不到的结果,而造成这种意外的原因可能是编译器优化、运行时系统的优化或者因为硬件的原因(即CPU和主存储器的通信模型)。可变(volatile)字段会限制这种优化的发生,在这里引入两个定义:

  • 可变读: 对于可变字段的读操作会获取语义。即,其可以保证对于可变字段的内存读取操作一定发生在其后内存操作指令的前面。进一步解释,与 Thread.MemoryBarrier 类似,获取语义会保证在读取可变字段指令前的指令可以跨越它出现在它后面,但是相反地,在它后面的指令不能跨越它出现在它的前面。例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Volatile_class
    {
    private int _a;
    private volatile int _b;
    private int _c;
    private void Call()
    {
    int temp=_a;
    //由于_b是可变字段,这样可以保证编译器不会将temp2=_c的指令提前到其之前
    //但是,可以将temp=_a提到其之后
    int temp1=_b;
    int temp2=_c;
    ...
    }
    private void OtherCall(){...}
    }
  • 可变写: 对于可变字段的写操作会释放语义。即,其可以保证对于可变字段的写操作发生在其前面指令执行之后,但是在它之后的指令可以跨域它提前执行。

X86_X64

现代的 x86_x64 CPU 可以保证字段的读写都是 “volatile” 的,即你不会读取到旧的字段值,这是由 CPU 提供保证的。这样看起来好像与上面的描述存在矛盾,如果 CPU 可以保证所有字段的读写都是 volatile ,那为什么还需要在语言层面提供volatile关键字。其实这是两个不同的概念,CPU 从硬件层面上保证了对内存的读写是实时的,你不会读取到 Stale Value ,无论这个字段是常规字段还是可变字段。而语言层面上的 volatile 只是一个关键字,告诉编译器不能对该字段进行 instruction reorder 等可能导致多线程读写出现不符合预期结果的优化(暂且这样理解)。

参考这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Program
{
class infinity_loop
{
public bool Terminated;
}
static void Main(string[] args)
{
var loop=new infinity_loop();
new Thread(()=>{
loop.Terminated=true;
}).Start();
while(!loop.Terminated);
}
}

使用 dotnet core Release 模式运行这段代码,可以发现它永远也不会退出,分析汇编代码:

查看更多

C# 尾递归优化

何为尾递归

有时候我们使用递归来解决一些特定的问题,但是使用递归需要注意不要导致栈溢出,这是使用递归的一个常见问题,对于规模足够大的问题,使用递归必定会导致栈溢出。通常,我们可以通过尾递归进行优化,尾递归可以避免栈溢出的问题(暂且这样认为)。
尾递归并不是什么新奇的东西,理解起来很简单,对于递归,如果上层调用的返回结果不依赖子调用的结果,那么,这就是一个尾递归。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
///这是一个简单的尾递归例子
namespace tail
{
class Program
{
static void Main(string[] args)
{
var test=RetrieveData(100000000000000);
}
static long RetrieveData(long unit)
{
if (unit == 0)
return unit;
return RetrieveData(--unit);
}
}
}

等等,好像有什么地方不对

从上面的例子分析,代码依旧会导致栈溢出,不是吗?是的,聪明的你答对了。那为什么说尾递归可以避免栈溢出问题?当然,从刚才的结论看,这个问题提的并不准确,尾递归并不能避免栈溢出问题。
仔细想想,尾递归结构和循环结构是类似的,上面的尾递归可以写成:

查看更多

业务逻辑中的 Filter

Pipe and Filter

在一些应用的开发场景中,例如,大量的数据处理、需要对数据进行大量的转换和过滤,使用管道和Filter是一个很好的选择。对于这种应用场景,一般都需要处理业务足够灵活,并且足够健壮。想象一下,为了实现相应的过滤功能,使用大量的if或者switch case,日后维护这套代码的人估计也会抓狂的吧,单元测试编写起来,应该也够呛吧。

Filter来处理业务

Filter一般都被认为是用来处理数据的,其实,更近一步,用来”处理”业务,也不失为一种好办法。当然,这里的处理,并不是说在Filter中执行业务,考虑到业务都是一些耗时(但不绝对)的操作,在管道中处理业务是不对的,这样会堵塞整条管道。相反地,我们的业务依赖管道中的数据,我们希望在数据处理的过程中,由数据的处理来触发特定的业务,因此,我们定义一种BusinessFilter,其不对数据进行处理,而是作为一种窥探,“假装”自己是在进行数据处理,实际是根据数据的实际来通知外界做相应的业务而已。
pipe.png

灵活性

正如上图的结构,搭积木式的链式结构可以根据业务的调整进行对应的调整,不同的业务可以在通道中穿插。从另一个方面可以看出,我们设计Filter的时候,保持它的独立性是至关重要的,一个Filter完成一个工作,而且不受其他Filter影响是保持整个链式结构灵活的关键。

健壮性

一般来说,管道/Filter结构一旦确定,会做大调整的几率都不高。由于Filter的存在,所有的变动在改动之前就可以确定影响范围,而且可以限制调整带来的结构变动。

存在的问题

  • 几乎所有的Pipe/Filter结构都是线性结构,这种情况限制了async/await的应用
  • 如果一个Filter只接受一种数据类型,那么存在的数据转换会降低我们系统的性能,特别地,如果是C#,进行引用类型和值类型的转换,频繁的装箱拆箱也会降低我们系统的性能;为了解决这种问题,限制Filter可接受的数据类型,这样就不能随意连接Filter到所有数据类型,通用性降低。

使用 IDE 编译调试 Mlt framework

Mlt framework 是一个开源跨平台的多媒体处理框架,使用模块化的设计,集成了大量的业界领先的视频处理框架,如ffmpeg,良好的设计,可以方便的集成自己的模块进去,利用它,你可以实现自己的 Adobe Premiere 等非线性多媒体编辑软件或者视频播放器,简单几句代码为视频添加炫酷的转场效果和滤镜。

由于跨平台,项目通过configure的方式来管理工程,对于学习来说多媒体框架来说,调试起来并不方便,我整理了CMake的脚本,可以通过cmake来生成我们熟悉的 Visual Studio 或者 Xcode
工程,方便调试。

在阅读下面文章之前,请确保你有一定的 MinGW 工具链使用经验。

下载源码

https://github.com/gandalfliang/mlt
分支:cmake

配置环境

对于 Windows 平台:

  1. cmake
  2. MinGW
  3. msys2
  4. Windows SDK

确保MinGW和msys2里的工具链路径在系统变量PATH中

Mac 平台:

  1. Xcode

Bootstrap

在工程跟目录,运行脚本(如果是 Windows 平台,从开发者命令行工具运行):

1
py bootstrap.py

如果一切顺利,工程文件将会在 build/win32 或者 build/mac 下。脚本运行要拉取ffmpeg的源码并configure,这是一个耗时的工作。在Windows平台下configure ffmpeg将是一个漫长的等待,没事的话,去喝杯咖啡,或者有空的时候在命令窗里敲一下enter键,可能会有收获哦。

完成后,你就可以愉快的调试这个框架了。

mac.png

Debug Mlt Framework in VSCode

Debugging Mlt framework in VSCode

mlt_vscode.png

Crossing platform with built in tool chain

mlt_vscode_osx.png

Coming up soon (maybe)

Gitsoler

Gitsoler 1.1 - Split View supported: https://marketplace.visualstudio.com/items?itemName=gandalfliang.gitsoler