summaryrefslogtreecommitdiff
path: root/Documentation/design-docs/finally-optimizations.md
blob: d35d5a47bd8ba585debd3319fbebb7d73e0b7e71 (plain)
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
Finally Optimizations
=====================

In MSIL, a try-finally is a construct where a block of code
(the finally) is guaranteed to be executed after control leaves a
protected region of code (the try) either normally or via an
exception.

In RyuJit a try-finally is currently implemented by transforming the
finally into a local function that is invoked via jitted code at normal
exits from the try block and is invoked via the runtime for exceptional
exits from the try block.

For x86 the local function is simply a part of the method and shares
the same stack frame with the method. For other architectures the
local function is promoted to a potentially separable "funclet"
which is almost like a regular function with a prolog and epilog. A
custom calling convention gives the funclet access to the parent stack
frame.

In this proposal we outline three optimizations for finallys: removing
empty trys, removing empty finallys and finally cloning.

Empty Finally Removal
---------------------

An empty finally is one that has no observable effect. These often
arise from `foreach` or `using` constructs (which induce a
try-finally) where the cleanup method called in the finally does
nothing. Often, after inlining, the empty finally is readily apparent.

For example, this snippet of C# code
```C#
static int Sum(List<int> x) {
    int sum = 0;
    foreach(int i in x) {
        sum += i;
    }
    return sum;
}
```
produces the following jitted code:
```asm
; Successfully inlined Enumerator[Int32][System.Int32]:Dispose():this
;    (1 IL bytes) (depth 1) [below ALWAYS_INLINE size]
G_M60484_IG01:
       55                   push     rbp
       57                   push     rdi
       56                   push     rsi
       4883EC50             sub      rsp, 80
       488D6C2460           lea      rbp, [rsp+60H]
       488BF1               mov      rsi, rcx
       488D7DD0             lea      rdi, [rbp-30H]
       B906000000           mov      ecx, 6
       33C0                 xor      rax, rax
       F3AB                 rep stosd
       488BCE               mov      rcx, rsi
       488965C0             mov      qword ptr [rbp-40H], rsp

G_M60484_IG02:
       33C0                 xor      eax, eax
       8945EC               mov      dword ptr [rbp-14H], eax
       8B01                 mov      eax, dword ptr [rcx]
       8B411C               mov      eax, dword ptr [rcx+28]
       33D2                 xor      edx, edx
       48894DD0             mov      gword ptr [rbp-30H], rcx
       8955D8               mov      dword ptr [rbp-28H], edx
       8945DC               mov      dword ptr [rbp-24H], eax
       8955E0               mov      dword ptr [rbp-20H], edx

G_M60484_IG03:
       488D4DD0             lea      rcx, bword ptr [rbp-30H]
       E89B35665B           call     Enumerator[Int32][System.Int32]:MoveNext():bool:this
       85C0                 test     eax, eax
       7418                 je       SHORT G_M60484_IG05

; Body of foreach loop

G_M60484_IG04:
       8B4DE0               mov      ecx, dword ptr [rbp-20H]
       8B45EC               mov      eax, dword ptr [rbp-14H]
       03C1                 add      eax, ecx
       8945EC               mov      dword ptr [rbp-14H], eax
       488D4DD0             lea      rcx, bword ptr [rbp-30H]
       E88335665B           call     Enumerator[Int32][System.Int32]:MoveNext():bool:this
       85C0                 test     eax, eax
       75E8                 jne      SHORT G_M60484_IG04

; Normal exit from the implicit try region created by `foreach`
; Calls the finally to dispose of the iterator

G_M60484_IG05:
       488BCC               mov      rcx, rsp
       E80C000000           call     G_M60484_IG09      // call to finally

G_M60484_IG06:
       90                   nop

G_M60484_IG07:
       8B45EC               mov      eax, dword ptr [rbp-14H]

G_M60484_IG08:
       488D65F0             lea      rsp, [rbp-10H]
       5E                   pop      rsi
       5F                   pop      rdi
       5D                   pop      rbp
       C3                   ret

; Finally funclet. Note it simply sets up and then tears down a stack
; frame. The dispose method was inlined and is empty.

G_M60484_IG09:
       55                   push     rbp
       57                   push     rdi
       56                   push     rsi
       4883EC30             sub      rsp, 48
       488B6920             mov      rbp, qword ptr [rcx+32]
       48896C2420           mov      qword ptr [rsp+20H], rbp
       488D6D60             lea      rbp, [rbp+60H]

G_M60484_IG10:
       4883C430             add      rsp, 48
       5E                   pop      rsi
       5F                   pop      rdi
       5D                   pop      rbp
       C3                   ret
```

In such cases the try-finally can be removed, leading to code like the following:
```asm
G_M60484_IG01:
       57                   push     rdi
       56                   push     rsi
       4883EC38             sub      rsp, 56
       488BF1               mov      rsi, rcx
       488D7C2420           lea      rdi, [rsp+20H]
       B906000000           mov      ecx, 6
       33C0                 xor      rax, rax
       F3AB                 rep stosd
       488BCE               mov      rcx, rsi

G_M60484_IG02:
       33F6                 xor      esi, esi
       8B01                 mov      eax, dword ptr [rcx]
       8B411C               mov      eax, dword ptr [rcx+28]
       48894C2420           mov      gword ptr [rsp+20H], rcx
       89742428             mov      dword ptr [rsp+28H], esi
       8944242C             mov      dword ptr [rsp+2CH], eax
       89742430             mov      dword ptr [rsp+30H], esi

G_M60484_IG03:
       488D4C2420           lea      rcx, bword ptr [rsp+20H]
       E8A435685B           call     Enumerator[Int32][System.Int32]:MoveNext():bool:this
       85C0                 test     eax, eax
       7414                 je       SHORT G_M60484_IG05

G_M60484_IG04:
       8B4C2430             mov      ecx, dword ptr [rsp+30H]
       03F1                 add      esi, ecx
       488D4C2420           lea      rcx, bword ptr [rsp+20H]
       E89035685B           call     Enumerator[Int32][System.Int32]:MoveNext():bool:this
       85C0                 test     eax, eax
       75EC                 jne      SHORT G_M60484_IG04

G_M60484_IG05:
       8BC6                 mov      eax, esi

G_M60484_IG06:
       4883C438             add      rsp, 56
       5E                   pop      rsi
       5F                   pop      rdi
       C3                   ret
```

Empty finally removal is unconditionally profitable: it should always
reduce code size and improve code speed.

Empty Try Removal
---------------------

If the try region of a try-finally is empty, and the jitted code will
execute on a runtime that does not protect finally execution from
thread abort, then the try-finally can be replaced with just the
content of the finally.

Empty trys with non-empty finallys often exist in code that must run
under both thread-abort aware and non-thread-abort aware runtimes. In
the former case the placement of cleanup code in the finally ensures
that the cleanup code will execute fully. But if thread abort is not
possible, the extra protection offered by the finally is not needed.

Empty try removal looks for try-finallys where the try region does
nothing except invoke the finally. There are currently two different
EH implementation models, so the try screening has two cases:

* callfinally thunks (x64/arm64): the try must be a single empty
basic block that always jumps to a callfinally that is the first
half of a callfinally/always pair;
* non-callfinally thunks (x86/arm32): the try must be a
callfinally/always pair where the first block is an empty callfinally.

The screening then verifies that the callfinally identified above is
the only callfinally for the try. No other callfinallys are expected
because this try cannot have multiple leaves and its handler cannot be
reached by nested exit paths.

When the empty try is identified, the jit modifies the
callfinally/always pair to branch to the handler, modifies the
handler's return to branch directly to the continuation (the
branch target of the second half of the callfinally/always pair),
updates various status flags on the blocks, and then removes the
try-finally region.

Finally Cloning
---------------

Finally cloning is an optimization where the jit duplicates the code
in the finally for one or more of the normal exit paths from the try,
and has those exit points branch to the duplicated code directly,
rather than calling the finally.  This transformation allows for
improved performance and optimization of the common case where the try
completes without an exception.

Finally cloning also allows hot/cold splitting of finally bodies: the
cloned finally code covers the normal try exit paths (the hot cases)
and can be placed in the main method region, and the original finally,
now used largely or exclusively for exceptional cases (the cold cases)
spilt off into the cold code region. Without cloning, RyuJit
would always treat the finally as cold code.

Finally cloning will increase code size, though often the size
increase is mitigated somewhat by more compact code generation in the
try body and streamlined invocation of the cloned finallys.

Try-finally regions may have multiple normal exit points. For example
the following `try` has two: one at the `return 3` and one at the try
region end:

```C#
try {
   if (p) return 3;
   ...
}
finally {
   ...
}
return 4;
```

Here the finally must be executed no matter how the try exits. So
there are to two normal exit paths from the try, both of which pass
through the finally but which then diverge. The fact that some try
regions can have multiple exits opens the potential for substantial
code growth from finally cloning, and so leads to a choice point in
the implementation:

* Share the clone along all exit paths
* Share the clone along some exit paths
* Clone along all exit paths
* Clone along some exit paths
* Only clone along one exit path
* Only clone when there is one exit path

The shared clone option must essentially recreate or simulate the
local call mechanism for the finally, though likely somewhat more
efficiently. Each exit point must designate where control should
resume once the shared finally has finished.  For instance the jit
could introduce a new local per try-finally to determine where the
cloned finally should resume, and enumerate the possibilities using a
small integer. The end of the cloned finally would then use a switch
to determine what code to execute next. This has the downside of
introducing unrealizable paths into the control flow graph.

Cloning along all exit paths can potentially lead to large amounts of
code growth.

Cloning along some paths or only one path implies that some normal
exit paths won't be as well optimized. Nonetheless cloning along one
path was the choice made by JIT64 and the one we recommend for
implementation. In particular we suggest only cloning along the end of
try region exit path, so that any early exit will continue to invoke
the funclet for finally cleanup (unless that exit happens to have the
same post-finally continuation as the end try region exit, in which
case it can simply jump to the cloned finally).

One can imagine adaptive strategies. The size of the finally can
be roughly estimated and the number of clones needed for full cloning
readily computed. Selective cloning can be based on profile
feedback or other similar mechanisms for choosing the profitable
cases.

The current implementation will clone the finally and retarget the
last (largest IL offset) leave in the try region to the clone. Any
other leave that ultimately transfers control to the same post-finally
offset will also be modified to jump to the clone.

Empirical studies have shown that most finallys are small. Thus to
avoid excessive code growth, a crude size estimate is formed by
counting the number of statements in the blocks that make up the
finally. Any finally larger that 15 statements is not cloned. In our
study this disqualifed about 0.5% of all finallys from cloning.

### EH Nesting Considerations

Finally cloning is also more complicated when the finally encloses
other EH regions, since the clone will introduce copies of all these
regions. While it is possible to implement cloning in such cases we
propose to defer for now.

Finally cloning is also a bit more complicated if the finally is
enclosed by another finally region, so we likewise propose deferring
support for this.  (Seems like a rare enough thing but maybe not too
hard to handle -- though possibly not worth it if we're not going to
support the enclosing case).

### Control-Flow and Other Considerations

If the try never exits normally, then the finally can only be invoked
in exceptional cases. There is no benefit to cloning since the cloned
finally would be unreachable. We can detect a subset of such cases
because there will be no call finally blocks.

JIT64 does not clone finallys that contained switch. We propose to
do likewise. (Initially I did not include this restriction but
hit a failing test case where the finally contained a switch. Might be
worth a deeper look, though such cases are presumably rare.)

If the finally never exits normally, then we presume it is cold code,
and so will not clone.

If the finally is marked as run rarely, we will not clone.

Implementation Proposal
-----------------------

We propose that empty finally removal and finally cloning be run back
to back, spliced into the phase list just after fgInline and
fgAddInternal, and just before implicit by-ref and struct
promotion. We want to run these early before a lot of structural
invariants regarding EH are put in place, and before most
other optimization, but run them after inlining
(so empty finallys can be more readily identified) and after the
addition of implicit try-finallys created by the jit.  Empty finallys
may arise later because of optimization, but this seems relatively
uncommon.

We will remove empty finallys first, then clone.

Neither optimization will run when the jit is generating debuggable
code or operating in min opts mode.

### Empty Finally Removal (Sketch)

Skip over methods that have no EH, are compiled with min opts, or
where the jit is generating debuggable code.

Walk the handler table, looking for try-finally (we could also look
for and remove try-faults with empty faults, but those are presumably
rare).

If the finally is a single block and contains only a `retfilter`
statement, then:

* Retarget the callfinally(s) to jump always to the continuation blocks.
* Remove the paired jump always block(s) (note we expect all finally
calls to be paired since the empty finally returns).
* For funclet EH models with finally target bits, clear the finally
target from the continuations.
* For non-funclet EH models only, clear out the GT_END_LFIN statement
in the finally continuations.
* Remove the handler block.
* Reparent all directly contained try blocks to the enclosing try region
or to the method region if there is no enclosing try.
* Remove the try-finally from the EH table via `fgRemoveEHTableEntry`.

After the walk, if any empty finallys were removed, revalidate the
integrity of the handler table.

### Finally Cloning (Sketch)

Skip over all methods, if the runtime suports thread abort. More on
this below.

Skip over methods that have no EH, are compiled with min opts, or
where the jit is generating debuggable code.

Walk the handler table, looking for try-finally. If the finally is
enclosed in a handler or encloses another handler, skip.

Walk the finally body blocks. If any is BBJ_SWITCH, or if none
is BBJ_EHFINALLYRET, skip cloning. If all blocks are RunRarely
skip cloning. If the finally has more that 15 statements, skip
cloning.

Walk the try region from back to front (from largest to smallest IL
offset). Find the last block in the try that invokes the finally. That
will be the path that will invoke the clone.

If the EH model requires callfinally thunks, and there are multiple
thunks that invoke the finally, and the callfinally thunk along the
clone path is not the first, move it to the front (this helps avoid
extra jumps).

Set the insertion point to just after the callfinally in the path (for
thunk models) or the end of the try (for non-thunk models).  Set up a
block map. Clone the finally body using `fgNewBBinRegion` and
`fgNewBBafter` to make the first and subsequent blocks, and
`CloneBlockState` to fill in the block contents. Clear the handler
region on the cloned blocks. Bail out if cloning fails. Mark the first
and last cloned blocks with appropriate BBF flags. Patch up inter-clone
branches and convert the returns into jumps to the continuation.

Walk the callfinallys, retargeting the ones that return to the
continuation so that they invoke the clone. Remove the paired always
blocks. Clear the finally target bit and any GT_END_LFIN from the
continuation.

If all call finallys are converted, modify the region to be try/fault
(interally EH_HANDLER_FAULT_WAS_FINALLY, so we can distinguish it
later from "organic" try/faults).  Otherwise leave it as a
try/finally.

Clear the catch type on the clone entry.

### Thread Abort

For runtimes that support thread abort (desktop), more work is
required:

* The cloned finally must be reported to the runtime. Likely this
can trigger off of the BBF_CLONED_FINALLY_BEGIN/END flags.
* The jit must maintain the integrity of the clone by not losing
track of the blocks involved, and not allowing code to move in our
out of the cloned region

Code Size Impact
----------------

Code size impact from finally cloning was measured for CoreCLR on
Windows x64.

```
Total bytes of diff: 16158 (0.12 % of base)
    diff is a regression.
Total byte diff includes 0 bytes from reconciling methods
        Base had    0 unique methods,        0 unique bytes
        Diff had    0 unique methods,        0 unique bytes
Top file regressions by size (bytes):
        3518 : Microsoft.CodeAnalysis.CSharp.dasm (0.16 % of base)
        1895 : System.Linq.Expressions.dasm (0.32 % of base)
        1626 : Microsoft.CodeAnalysis.VisualBasic.dasm (0.07 % of base)
        1428 : System.Threading.Tasks.Parallel.dasm (4.66 % of base)
        1248 : System.Linq.Parallel.dasm (0.20 % of base)
Top file improvements by size (bytes):
       -4529 : System.Private.CoreLib.dasm (-0.14 % of base)
        -975 : System.Reflection.Metadata.dasm (-0.28 % of base)
        -239 : System.Private.Uri.dasm (-0.27 % of base)
        -104 : System.Runtime.InteropServices.RuntimeInformation.dasm (-3.36 % of base)
         -99 : System.Security.Cryptography.Encoding.dasm (-0.61 % of base)
57 total files with size differences.
Top method regessions by size (bytes):
         645 : System.Diagnostics.Process.dasm - System.Diagnostics.Process:StartCore(ref):bool:this
         454 : Microsoft.CSharp.dasm - Microsoft.CSharp.RuntimeBinder.Semantics.ExpressionBinder:AdjustCallArgumentsForParams(ref,ref,ref,ref,ref,byref):this
         447 : System.Threading.Tasks.Dataflow.dasm - System.Threading.Tasks.Dataflow.Internal.SpscTargetCore`1[__Canon][System.__Canon]:ProcessMessagesLoopCore():this
         421 : Microsoft.CodeAnalysis.VisualBasic.dasm - Microsoft.CodeAnalysis.VisualBasic.Symbols.ImplementsHelper:FindExplicitlyImplementedMember(ref,ref,ref,ref,ref,ref,byref):ref
         358 : System.Private.CoreLib.dasm - System.Threading.TimerQueueTimer:Change(int,int):bool:this
Top method improvements by size (bytes):
       -2512 : System.Private.CoreLib.dasm - DomainNeutralILStubClass:IL_STUB_CLRtoWinRT():ref:this (68 methods)
        -824 : Microsoft.CodeAnalysis.dasm - Microsoft.Cci.PeWriter:WriteHeaders(ref,ref,ref,ref,byref):this
        -663 : System.Private.CoreLib.dasm - DomainNeutralILStubClass:IL_STUB_CLRtoWinRT(ref):int:this (17 methods)
        -627 : System.Private.CoreLib.dasm - System.Diagnostics.Tracing.ManifestBuilder:CreateManifestString():ref:this
        -546 : System.Private.CoreLib.dasm - DomainNeutralILStubClass:IL_STUB_WinRTtoCLR(long):int:this (67 methods)
3014 total methods with size differences.
```

The largest growth is seen in `Process:StartCore`, which has 4
try-finally constructs.

Diffs generally show improved codegen in the try bodies with cloned
finallys. However some of this improvement comes from more aggressive
use of callee save registers, and this causes size inflation in the
funclets (note finally cloning does not alter the number of
funclets). So if funclet save/restore could be contained to registers
used in the funclet, the size impact would be slightly smaller.

There are also some instances where cloning relatively small finallys
leads to large code size increases. xxx is one example.