Admittedly, I am unsure whether I am correctly comparing apples with apples or apples with pears here. But I'm particularly surprised about the bigness of the difference, where a slighter difference, if any, would be expected.
Piping can often be expressed as function composition and vice versa, and I would assume the compiler knows that too, so I tried a little experiment:
// simplified example of some SB helpers:
let inline bcreate() = new StringBuilder(64)
let inline bget (sb: StringBuilder) = sb.ToString()
let inline appendf fmt (sb: StringBuilder) = Printf.kbprintf (fun () -> sb) sb fmt
let inline appends (s: string) (sb: StringBuilder) = sb.Append s
let inline appendi (i: int) (sb: StringBuilder) = sb.Append i
let inline appendb (b: bool) (sb: StringBuilder) = sb.Append b
// test function for composition, putting some garbage data in SB
let compose a =
(appends "START"
>> appendb true
>> appendi 10
>> appendi a
>> appends "0x"
>> appendi 65535
>> appendi 10
>> appends "test"
>> appends "END") (bcreate())
// test function for piping, putting the same garbage data in SB
let pipe a =
bcreate()
|> appends "START"
|> appendb true
|> appendi 10
|> appendi a
|> appends "0x"
|> appendi 65535
|> appendi 10
|> appends "test"
|> appends "END"
Testing this in FSI (64 bit enabled, --optimize
flag on) gives:
> for i in 1 .. 500000 do compose 123 |> ignore;;
Real: 00:00:00.390, CPU: 00:00:00.390, GC gen0: 62, gen1: 1, gen2: 0
val it : unit = ()
> for i in 1 .. 500000 do pipe 123 |> ignore;;
Real: 00:00:00.249, CPU: 00:00:00.249, GC gen0: 27, gen1: 0, gen2: 0
val it : unit = ()
A small difference would be understandable, but this is a factor 1.6 (60%) performance degradation.
I would actually expect the bulk of the work to happen in the StringBuilder
, but apparently the overhead of composition has quite a bit of influence.
I realize that in most practical situations this difference will be negligible, but if you are writing large formatted text files (like log files) as in this case, it has an impact.
I am using the latest version of F#.
The composition f∘g of two functions f and g is the function formed by first applying the function g and then the function f. In other words, to apply the composition f∘g to an input x, you perform the following two steps. You first apply the function g to the input x and obtain the result g(x) as the output.
What is function composition? Function composition is where we take two functions, and combine them into one. That is, our new function calls one function, takes the result, and passes it into another function. That's it.
The composition operator (○) indicates that we should substitute one function into another. In other words, (f○g)(x)=f(g(x)) indicates that we substitute g(x) into f(x). If two functions are inverses, then each will reverse the effect of the other. Using notation, (f○g)(x)=f(g(x))=x and (g○f)(x)=g(f(x))=x.
The composition of two functions g and f is the new function we get by performing f first, and then performing g. For example, if we let f be the function given by f(x) = x2 and let g be the function given by g(x) = x + 3, then the composition of g with f is called gf and is worked out as gf(x) = g(f(x)) .
I tried out your example with FSI and found no appreciable difference:
> #time
for i in 1 .. 500000 do compose 123 |> ignore
--> Timing now on
Real: 00:00:00.229, CPU: 00:00:00.234, GC gen0: 32, gen1: 32, gen2: 0
val it : unit = ()
> #time;;
--> Timing now off
> #time
for i in 1 .. 500000 do pipe 123 |> ignore;;;;
--> Timing now on
Real: 00:00:00.214, CPU: 00:00:00.218, GC gen0: 30, gen1: 30, gen2: 0
val it : unit = ()
Measuring it in BenchmarkDotNet (The first table is just a single compose/pipe run, the 2nd table is doing it 500000 times), I found something similar:
Method | Platform | Jit | Median | StdDev | Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
-------- |--------- |---------- |------------ |----------- |--------- |------ |------ |------------------- |
compose | X64 | RyuJit | 319.7963 ns | 5.0299 ns | 2,848.50 | - | - | 182.54 |
pipe | X64 | RyuJit | 308.5887 ns | 11.3793 ns | 2,453.82 | - | - | 155.88 |
compose | X86 | LegacyJit | 428.0141 ns | 3.6112 ns | 1,970.00 | - | - | 126.85 |
pipe | X86 | LegacyJit | 416.3469 ns | 8.0869 ns | 1,886.00 | - | - | 121.86 |
Method | Platform | Jit | Median | StdDev | Gen 0 | Gen 1 | Gen 2 | Bytes Allocated/Op |
-------- |--------- |---------- |------------ |---------- |--------- |------ |------ |------------------- |
compose | X64 | RyuJit | 160.8059 ms | 4.6699 ms | 3,514.75 | - | - | 56,224,980.75 |
pipe | X64 | RyuJit | 163.1026 ms | 4.9829 ms | 3,120.00 | - | - | 50,025,686.21 |
compose | X86 | LegacyJit | 215.8562 ms | 4.2769 ms | 2,292.00 | - | - | 36,820,936.68 |
pipe | X86 | LegacyJit | 209.9219 ms | 2.5605 ms | 2,220.00 | - | - | 35,554,575.32 |
It may be that differences you are measuring are related to GC. Try to force a GC collect before/after your timings.
That said, looking at the source code for the pipe operator:
let inline (|>) x f = f x
and comparing against the composition operator:
let inline (>>) f g x = g(f x)
seems to make it clear that the composition operator will be creating lambda functions, which should result in more allocations. This can also be seen in the BenchmarkDotNet runs. That might also be the cause for the performance difference you are seeing.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With