View Issue Details

IDProjectCategoryView StatusLast Update
0036355FPCCompilerpublic2019-11-25 22:16
ReporterJ. Gareth MoretonAssigned ToFlorian 
PrioritynormalSeverityminorReproducibilityN/A
Status resolvedResolutionfixed 
Platformi386 and x86_64OSMicrosoft WindowsOS Version10 Professional
Product Version3.3.1Product Buildr43582 
Target VersionFixed in Version3.3.1 
Summary0036355: [Patch] JMP -> MOV/RET optimisation
DescriptionThis patch serves to extend the JMP -> RET optimisation in OptPass2JMP by also doing the same for JMP -> MOV/RET, since there are often cases where the result (e.g. EAX) is set just prior to the function exiting. If future C-Builder support is ever implemented, this optimisation will serve that language well because "return 1;", for example, sets the result and exits the function in a single instruction.

As an example of this optimisation in action in lazarus/ide/etfpcmsgparser.pas (~line 7481) - BEFORE:

    ...
.Lj1187:
    testl %edx,%edx
    je .Lj1189
    subl $1,%edx
    je .Lj1190
    jmp .Lj1188
    .balign 16,0x90
.Lj1189:
    movb $1,97(%rcx)
    jmp .Lj1188
    .balign 16,0x90
.Lj1190:
    movb $1,96(%rcx)
    .balign 16,0x90
.Lj1188:
    movb $1,%al
.Lc635:
    ret
.Lc633:

---
AFTER:

    ...
.Lj1187:
    testl %edx,%edx
    je .Lj1189
    subl $1,%edx
    je .Lj1190
    movb $1,%al
    ret
    .balign 16,0x90
.Lj1189:
    movb $1,97(%rcx)
    movb $1,%al
    ret
    .balign 16,0x90
.Lj1190:
    movb $1,96(%rcx)
    movb $1,%al
.Lc635:
    ret
.Lc633:

(.LJ1188 becomes a dead label and is subsequently stripped)
Steps To ReproduceApply patch and confirm correct code compilation as well as a minor speed boost caused by the elimination of jumps.
Additional InformationBy itself, this optimisation can cause code size to increase, but sometimes an optimisation can be made to the MOV instructions that are added. To accommodate for this, OptPass2MOV calls OptPass2JMP if the instruction that follows is JMP, and then calls OptPass1MOV if an optimisation is made. At the same time, a call to "GetNextInstruction" was factored out of the if-conditions in OptPass2MOV, providing a boost in efficiency, since "GetNextInstruction" is a rather expensive call.

Because of the complexity of this optimisation process, it is only performed when optimising for speed, or under -O3.

Currently, the JMP -> MOV/RET optimisation cannot be moved to pass 1 in order to simplify the above process, because it causes other optimisations to perform worse (most notably, the Jcc -> CMOVcc optimisation).

As a result, there is potential for future research in this part of the optimisation process, not just from the intermixing of pass 1 and pass 2, but also noting in the above example that mov $1,%al appears in both branches of a conditional jump, something that may require more complex data-flow analysis to detect and optimise (by inserting the MOV instruction to before the jump and removing the copies that appear after if safe to do so).
Tagscompiler, i386, optimizations, patch, x86, x86_64
Fixed in Revision43592
FPCOldBugId
FPCTarget-
Attached Files
  • jmp-mov-ret-optimisations.patch (10,111 bytes)
    Index: compiler/aoptobj.pas
    ===================================================================
    --- compiler/aoptobj.pas	(revision 43582)
    +++ compiler/aoptobj.pas	(working copy)
    @@ -1616,6 +1616,15 @@
     
         { Removes all instructions between an unconditional jump and the next label }
         procedure TAOptObj.RemoveDeadCodeAfterJump(p: tai);
    +      const
    +{$ifdef JVM}
    +        TaiFence = SkipInstr + [ait_const, ait_realconst, ait_typedconst, ait_label, ait_jcatch];
    +{$else JVM}
    +        { Stop if it reaches SEH directive information in the form of
    +          consts, which may occur if RemoveDeadCodeAfterJump is called on
    +          the final RET instruction on x86, for example }
    +        TaiFence = SkipInstr + [ait_const, ait_realconst, ait_typedconst, ait_label];
    +{$endif JVM}
           var
             hp1, hp2: tai;
           begin
    @@ -1624,12 +1633,7 @@
             }
             while GetNextInstruction(p, hp1) and
                   (hp1 <> BlockEnd) and
    -              (hp1.typ <> ait_label)
    -{$ifdef JVM}
    -              and (hp1.typ <> ait_jcatch)
    -{$endif}
    -              do
    -          if not(hp1.typ in ([ait_label]+skipinstr)) then
    +              not (hp1.typ in TaiFence) do
                 begin
                   if (hp1.typ = ait_instruction) and
                      taicpu(hp1).is_jmp and
    @@ -1658,9 +1662,7 @@
                     end
                   else
                     p:=hp1;
    -            end
    -          else
    -            Break;
    +            end;
           end;
     
         { If hp is a label, strip it if its reference count is zero.  Repeat until
    Index: compiler/x86/aoptx86.pas
    ===================================================================
    --- compiler/x86/aoptx86.pas	(revision 43582)
    +++ compiler/x86/aoptx86.pas	(working copy)
    @@ -93,6 +93,8 @@
             function PostPeepholeOptLea(var p : tai) : Boolean;
     
             procedure OptReferences;
    +
    +        procedure ConvertJumpToRET(const p: tai; const ret_p: tai);
           end;
     
         function MatchInstruction(const instr: tai; const op: TAsmOp; const opsize: topsizes): boolean;
    @@ -3107,8 +3109,47 @@
     {$endif x86_64}
           begin
             Result:=false;
    -        if MatchOpType(taicpu(p),top_reg,top_reg) and
    -          GetNextInstruction(p, hp1) and
    +        if not GetNextInstruction(p, hp1) then
    +          Exit;
    +
    +        if MatchInstruction(hp1, A_JMP, [S_NO]) then
    +          begin
    +            { Sometimes the MOVs that OptPass2JMP produces can be improved
    +              further, but we can't just put this jump optimisation in pass 1
    +              because it tends to perform worse when conditional jumps are
    +              nearby (e.g. when converting CMOV instructions). [Kit] }
    +            if OptPass2JMP(hp1) then
    +              { call OptPass1MOV once to potentially merge any MOVs that were created }
    +              Result := OptPass1MOV(p)
    +              { OptPass2MOV will now exit but will be called again if OptPass1MOV
    +                returned True and the instruction is still a MOV, thus checking
    +                the optimisations below }
    +            else
    +              { Since OptPass2JMP returned false, no optimisations were done to
    +                the jump. Additionally, a label will definitely follow the jump
    +                (although it may have become dead), so skip ahead as far as
    +                possible }
    +              begin
    +                while (p <> hp1) do
    +                  begin
    +                    { Nothing changed between the MOV and the JMP, so
    +                      don't bother with "UpdateUsedRegsAndOptimize" }
    +                    UpdateUsedRegs(p);
    +                    p := tai(p.Next);
    +                  end;
    +
    +                { Use "UpdateUsedRegsAndOptimize" here though, because the
    +                  label might now be dead and can be stripped out }
    +                p := tai(UpdateUsedRegsAndOptimize(hp1).Next);
    +
    +                { If p is a label, then Result will be False and program flow
    +                  will move onto the next list entry in "PeepHoleOptPass2" }
    +                if (p = BlockEnd) or not (p.typ in [ait_align, ait_label]) then
    +                  Result := True;
    +
    +              end;
    +          end
    +        else if MatchOpType(taicpu(p),top_reg,top_reg) and
     {$ifdef x86_64}
               MatchInstruction(hp1,A_MOVZX,A_MOVSX,A_MOVSXD,[]) and
     {$else x86_64}
    @@ -3141,7 +3182,6 @@
                 exit;
               end
             else if MatchOpType(taicpu(p),top_reg,top_reg) and
    -          GetNextInstruction(p, hp1) and
     {$ifdef x86_64}
               MatchInstruction(hp1,[A_MOV,A_MOVZX,A_MOVSX,A_MOVSXD],[]) and
     {$else x86_64}
    @@ -3168,7 +3208,6 @@
                 exit;
               end
             else if (taicpu(p).oper[0]^.typ = top_ref) and
    -          GetNextInstruction(p,hp1) and
               (hp1.typ = ait_instruction) and
               { while the GetNextInstruction(hp1,hp2) call could be factored out,
                 doing it separately in both branches allows to do the cheap checks
    @@ -3236,7 +3275,6 @@
             else if (taicpu(p).opsize = S_L) and
               (taicpu(p).oper[1]^.typ = top_reg) and
               (
    -            GetNextInstruction(p, hp1) and
                 MatchInstruction(hp1, A_MOV,[]) and
                 (taicpu(hp1).opsize = S_L) and
                 (taicpu(hp1).oper[1]^.typ = top_reg)
    @@ -3365,40 +3403,100 @@
           end;
     
     
    +    procedure TX86AsmOptimizer.ConvertJumpToRET(const p: tai; const ret_p: tai);
    +      var
    +        ThisLabel: TAsmLabel;
    +      begin
    +        ThisLabel := tasmlabel(taicpu(p).oper[0]^.ref^.symbol);
    +        ThisLabel.decrefs;
    +        taicpu(p).opcode := A_RET;
    +        taicpu(p).is_jmp := false;
    +        taicpu(p).ops := taicpu(ret_p).ops;
    +        case taicpu(ret_p).ops of
    +          0:
    +            taicpu(p).clearop(0);
    +          1:
    +            taicpu(p).loadconst(0,taicpu(ret_p).oper[0]^.val);
    +          else
    +            internalerror(2016041301);
    +        end;
    +
    +        { If the original label is now dead, it might turn out that the label
    +          immediately follows p.  As a result, everything beyond it, which will
    +          be just some final register configuration and a RET instruction, is
    +          now dead code. [Kit] }
    +
    +        { NOTE: This is much faster than introducing a OptPass2RET routine and
    +          running RemoveDeadCodeAfterJump for each RET instruction, because
    +          this optimisation rarely happens and most RETs appear at the end of
    +          routines where there is nothing that can be stripped. [Kit] }
    +        if not ThisLabel.is_used then
    +          RemoveDeadCodeAfterJump(p);
    +      end;
    +
    +
         function TX86AsmOptimizer.OptPass2Jmp(var p : tai) : boolean;
           var
    -        hp1 : tai;
    +        hp1, hp2 : tai;
           begin
    -        {
    -          change
    -                 jmp .L1
    -                 ...
    -             .L1:
    -                 ret
    -          into
    -                 ret
    -        }
             result:=false;
             if (taicpu(p).oper[0]^.typ=top_ref) and (taicpu(p).oper[0]^.ref^.refaddr=addr_full) and (taicpu(p).oper[0]^.ref^.base=NR_NO) and
               (taicpu(p).oper[0]^.ref^.index=NR_NO) then
               begin
                 hp1:=getlabelwithsym(tasmlabel(taicpu(p).oper[0]^.ref^.symbol));
    -            if (taicpu(p).condition=C_None) and assigned(hp1) and SkipLabels(hp1,hp1) and
    -              MatchInstruction(hp1,A_RET,[S_NO]) then
    +            if (taicpu(p).condition=C_None) and assigned(hp1) and SkipLabels(hp1,hp1) and (hp1.typ = ait_instruction) then
                   begin
    -                tasmlabel(taicpu(p).oper[0]^.ref^.symbol).decrefs;
    -                taicpu(p).opcode:=A_RET;
    -                taicpu(p).is_jmp:=false;
    -                taicpu(p).ops:=taicpu(hp1).ops;
    -                case taicpu(hp1).ops of
    -                  0:
    -                    taicpu(p).clearop(0);
    -                  1:
    -                    taicpu(p).loadconst(0,taicpu(hp1).oper[0]^.val);
    +                case taicpu(hp1).opcode of
    +                  A_RET:
    +                    {
    +                      change
    +                             jmp .L1
    +                             ...
    +                         .L1:
    +                             ret
    +                      into
    +                             ret
    +                    }
    +                    begin
    +                      ConvertJumpToRET(p, hp1);
    +                      result:=true;
    +                    end;
    +                  A_MOV:
    +                    {
    +                      change
    +                             jmp .L1
    +                             ...
    +                         .L1:
    +                             mov ##, ##
    +                             ret
    +                      into
    +                             mov ##, ##
    +                             ret
    +                    }
    +                    { This optimisation tends to increase code size if the pass 1 MOV optimisations aren't
    +                      re-run, so only do this particular optimisation if optimising for speed or when
    +                      optimisations are very in-depth. [Kit] }
    +                    if (current_settings.optimizerswitches * [cs_opt_level3, cs_opt_size]) <> [cs_opt_size] then
    +                      begin
    +                        GetNextInstruction(hp1, hp2);
    +                        if not Assigned(hp2) then
    +                          Exit;
    +
    +                        if (hp2.typ in [ait_label, ait_align]) then
    +                          SkipLabels(hp2,hp2);
    +                        if Assigned(hp2) and MatchInstruction(hp2, A_RET, [S_NO]) then
    +                          begin
    +                            { Duplicate the MOV instruction }
    +                            asml.InsertBefore(hp1.getcopy, p);
    +
    +                            { Now change the jump into a RET instruction }
    +                            ConvertJumpToRET(p, hp2);
    +                            result:=true;
    +                          end;
    +                      end;
                       else
    -                    internalerror(2016041301);
    +                    { Do nothing };
                     end;
    -                result:=true;
                   end;
               end;
           end;
    

Activities

J. Gareth Moreton

2019-11-25 09:21

developer   ~0119484

Last edited: 2019-11-25 09:22

View 2 revisions

Added a change that I forgot to include in the patch. RemoveDeadCodeAfterJump will now drop out if it detects SEH information - this stops exception information from being stripped if it is called on the final RET instruction. It is unlikely that it will be triggered though, since the new optimisation only calls the function if an optimisation is made (which won't occur at the very end of an assembler routine). Nevertheless. it is kept for safety reasons.



jmp-mov-ret-optimisations.patch (10,111 bytes)
Index: compiler/aoptobj.pas
===================================================================
--- compiler/aoptobj.pas	(revision 43582)
+++ compiler/aoptobj.pas	(working copy)
@@ -1616,6 +1616,15 @@
 
     { Removes all instructions between an unconditional jump and the next label }
     procedure TAOptObj.RemoveDeadCodeAfterJump(p: tai);
+      const
+{$ifdef JVM}
+        TaiFence = SkipInstr + [ait_const, ait_realconst, ait_typedconst, ait_label, ait_jcatch];
+{$else JVM}
+        { Stop if it reaches SEH directive information in the form of
+          consts, which may occur if RemoveDeadCodeAfterJump is called on
+          the final RET instruction on x86, for example }
+        TaiFence = SkipInstr + [ait_const, ait_realconst, ait_typedconst, ait_label];
+{$endif JVM}
       var
         hp1, hp2: tai;
       begin
@@ -1624,12 +1633,7 @@
         }
         while GetNextInstruction(p, hp1) and
               (hp1 <> BlockEnd) and
-              (hp1.typ <> ait_label)
-{$ifdef JVM}
-              and (hp1.typ <> ait_jcatch)
-{$endif}
-              do
-          if not(hp1.typ in ([ait_label]+skipinstr)) then
+              not (hp1.typ in TaiFence) do
             begin
               if (hp1.typ = ait_instruction) and
                  taicpu(hp1).is_jmp and
@@ -1658,9 +1662,7 @@
                 end
               else
                 p:=hp1;
-            end
-          else
-            Break;
+            end;
       end;
 
     { If hp is a label, strip it if its reference count is zero.  Repeat until
Index: compiler/x86/aoptx86.pas
===================================================================
--- compiler/x86/aoptx86.pas	(revision 43582)
+++ compiler/x86/aoptx86.pas	(working copy)
@@ -93,6 +93,8 @@
         function PostPeepholeOptLea(var p : tai) : Boolean;
 
         procedure OptReferences;
+
+        procedure ConvertJumpToRET(const p: tai; const ret_p: tai);
       end;
 
     function MatchInstruction(const instr: tai; const op: TAsmOp; const opsize: topsizes): boolean;
@@ -3107,8 +3109,47 @@
 {$endif x86_64}
       begin
         Result:=false;
-        if MatchOpType(taicpu(p),top_reg,top_reg) and
-          GetNextInstruction(p, hp1) and
+        if not GetNextInstruction(p, hp1) then
+          Exit;
+
+        if MatchInstruction(hp1, A_JMP, [S_NO]) then
+          begin
+            { Sometimes the MOVs that OptPass2JMP produces can be improved
+              further, but we can't just put this jump optimisation in pass 1
+              because it tends to perform worse when conditional jumps are
+              nearby (e.g. when converting CMOV instructions). [Kit] }
+            if OptPass2JMP(hp1) then
+              { call OptPass1MOV once to potentially merge any MOVs that were created }
+              Result := OptPass1MOV(p)
+              { OptPass2MOV will now exit but will be called again if OptPass1MOV
+                returned True and the instruction is still a MOV, thus checking
+                the optimisations below }
+            else
+              { Since OptPass2JMP returned false, no optimisations were done to
+                the jump. Additionally, a label will definitely follow the jump
+                (although it may have become dead), so skip ahead as far as
+                possible }
+              begin
+                while (p <> hp1) do
+                  begin
+                    { Nothing changed between the MOV and the JMP, so
+                      don't bother with "UpdateUsedRegsAndOptimize" }
+                    UpdateUsedRegs(p);
+                    p := tai(p.Next);
+                  end;
+
+                { Use "UpdateUsedRegsAndOptimize" here though, because the
+                  label might now be dead and can be stripped out }
+                p := tai(UpdateUsedRegsAndOptimize(hp1).Next);
+
+                { If p is a label, then Result will be False and program flow
+                  will move onto the next list entry in "PeepHoleOptPass2" }
+                if (p = BlockEnd) or not (p.typ in [ait_align, ait_label]) then
+                  Result := True;
+
+              end;
+          end
+        else if MatchOpType(taicpu(p),top_reg,top_reg) and
 {$ifdef x86_64}
           MatchInstruction(hp1,A_MOVZX,A_MOVSX,A_MOVSXD,[]) and
 {$else x86_64}
@@ -3141,7 +3182,6 @@
             exit;
           end
         else if MatchOpType(taicpu(p),top_reg,top_reg) and
-          GetNextInstruction(p, hp1) and
 {$ifdef x86_64}
           MatchInstruction(hp1,[A_MOV,A_MOVZX,A_MOVSX,A_MOVSXD],[]) and
 {$else x86_64}
@@ -3168,7 +3208,6 @@
             exit;
           end
         else if (taicpu(p).oper[0]^.typ = top_ref) and
-          GetNextInstruction(p,hp1) and
           (hp1.typ = ait_instruction) and
           { while the GetNextInstruction(hp1,hp2) call could be factored out,
             doing it separately in both branches allows to do the cheap checks
@@ -3236,7 +3275,6 @@
         else if (taicpu(p).opsize = S_L) and
           (taicpu(p).oper[1]^.typ = top_reg) and
           (
-            GetNextInstruction(p, hp1) and
             MatchInstruction(hp1, A_MOV,[]) and
             (taicpu(hp1).opsize = S_L) and
             (taicpu(hp1).oper[1]^.typ = top_reg)
@@ -3365,40 +3403,100 @@
       end;
 
 
+    procedure TX86AsmOptimizer.ConvertJumpToRET(const p: tai; const ret_p: tai);
+      var
+        ThisLabel: TAsmLabel;
+      begin
+        ThisLabel := tasmlabel(taicpu(p).oper[0]^.ref^.symbol);
+        ThisLabel.decrefs;
+        taicpu(p).opcode := A_RET;
+        taicpu(p).is_jmp := false;
+        taicpu(p).ops := taicpu(ret_p).ops;
+        case taicpu(ret_p).ops of
+          0:
+            taicpu(p).clearop(0);
+          1:
+            taicpu(p).loadconst(0,taicpu(ret_p).oper[0]^.val);
+          else
+            internalerror(2016041301);
+        end;
+
+        { If the original label is now dead, it might turn out that the label
+          immediately follows p.  As a result, everything beyond it, which will
+          be just some final register configuration and a RET instruction, is
+          now dead code. [Kit] }
+
+        { NOTE: This is much faster than introducing a OptPass2RET routine and
+          running RemoveDeadCodeAfterJump for each RET instruction, because
+          this optimisation rarely happens and most RETs appear at the end of
+          routines where there is nothing that can be stripped. [Kit] }
+        if not ThisLabel.is_used then
+          RemoveDeadCodeAfterJump(p);
+      end;
+
+
     function TX86AsmOptimizer.OptPass2Jmp(var p : tai) : boolean;
       var
-        hp1 : tai;
+        hp1, hp2 : tai;
       begin
-        {
-          change
-                 jmp .L1
-                 ...
-             .L1:
-                 ret
-          into
-                 ret
-        }
         result:=false;
         if (taicpu(p).oper[0]^.typ=top_ref) and (taicpu(p).oper[0]^.ref^.refaddr=addr_full) and (taicpu(p).oper[0]^.ref^.base=NR_NO) and
           (taicpu(p).oper[0]^.ref^.index=NR_NO) then
           begin
             hp1:=getlabelwithsym(tasmlabel(taicpu(p).oper[0]^.ref^.symbol));
-            if (taicpu(p).condition=C_None) and assigned(hp1) and SkipLabels(hp1,hp1) and
-              MatchInstruction(hp1,A_RET,[S_NO]) then
+            if (taicpu(p).condition=C_None) and assigned(hp1) and SkipLabels(hp1,hp1) and (hp1.typ = ait_instruction) then
               begin
-                tasmlabel(taicpu(p).oper[0]^.ref^.symbol).decrefs;
-                taicpu(p).opcode:=A_RET;
-                taicpu(p).is_jmp:=false;
-                taicpu(p).ops:=taicpu(hp1).ops;
-                case taicpu(hp1).ops of
-                  0:
-                    taicpu(p).clearop(0);
-                  1:
-                    taicpu(p).loadconst(0,taicpu(hp1).oper[0]^.val);
+                case taicpu(hp1).opcode of
+                  A_RET:
+                    {
+                      change
+                             jmp .L1
+                             ...
+                         .L1:
+                             ret
+                      into
+                             ret
+                    }
+                    begin
+                      ConvertJumpToRET(p, hp1);
+                      result:=true;
+                    end;
+                  A_MOV:
+                    {
+                      change
+                             jmp .L1
+                             ...
+                         .L1:
+                             mov ##, ##
+                             ret
+                      into
+                             mov ##, ##
+                             ret
+                    }
+                    { This optimisation tends to increase code size if the pass 1 MOV optimisations aren't
+                      re-run, so only do this particular optimisation if optimising for speed or when
+                      optimisations are very in-depth. [Kit] }
+                    if (current_settings.optimizerswitches * [cs_opt_level3, cs_opt_size]) <> [cs_opt_size] then
+                      begin
+                        GetNextInstruction(hp1, hp2);
+                        if not Assigned(hp2) then
+                          Exit;
+
+                        if (hp2.typ in [ait_label, ait_align]) then
+                          SkipLabels(hp2,hp2);
+                        if Assigned(hp2) and MatchInstruction(hp2, A_RET, [S_NO]) then
+                          begin
+                            { Duplicate the MOV instruction }
+                            asml.InsertBefore(hp1.getcopy, p);
+
+                            { Now change the jump into a RET instruction }
+                            ConvertJumpToRET(p, hp2);
+                            result:=true;
+                          end;
+                      end;
                   else
-                    internalerror(2016041301);
+                    { Do nothing };
                 end;
-                result:=true;
               end;
           end;
       end;

Florian

2019-11-25 22:16

administrator   ~0119495

Thanks, applied.

Issue History

Date Modified Username Field Change
2019-11-25 09:08 J. Gareth Moreton New Issue
2019-11-25 09:08 J. Gareth Moreton File Added: jmp-mov-ret-optimisations.patch
2019-11-25 09:09 J. Gareth Moreton Tag Attached: patch
2019-11-25 09:09 J. Gareth Moreton Tag Attached: compiler
2019-11-25 09:09 J. Gareth Moreton Tag Attached: optimizations
2019-11-25 09:09 J. Gareth Moreton Tag Attached: x86_64
2019-11-25 09:09 J. Gareth Moreton Tag Attached: x86
2019-11-25 09:09 J. Gareth Moreton Tag Attached: i386
2019-11-25 09:18 J. Gareth Moreton File Deleted: jmp-mov-ret-optimisations.patch
2019-11-25 09:21 J. Gareth Moreton File Added: jmp-mov-ret-optimisations.patch
2019-11-25 09:21 J. Gareth Moreton Note Added: 0119484
2019-11-25 09:22 J. Gareth Moreton Note Edited: 0119484 View Revisions
2019-11-25 22:16 Florian Assigned To => Florian
2019-11-25 22:16 Florian Status new => resolved
2019-11-25 22:16 Florian Resolution open => fixed
2019-11-25 22:16 Florian Fixed in Version => 3.3.1
2019-11-25 22:16 Florian Fixed in Revision => 43592
2019-11-25 22:16 Florian FPCTarget => -
2019-11-25 22:16 Florian Note Added: 0119495