【问题标题】:How to wrap sprintf conditionally in F#?如何在 F# 中有条件地包装 sprintf?
【发布时间】:2015-10-05 05:08:29
【问题描述】:

我读过一个类似的问题:Magic sprintf function - how to wrap it?,但我的要求有点不同,所以我想知道它是否可行。

首先,我想稍微解释一下场景,我目前有一个类似的跟踪功能

let Trace traceLevel ( fs : unit -> string) =
    if traceLevel <= Config.TraceLevel then
        Trace.WriteLine <| fs()

因此只有当traceLevel小于或等于Config.TraceLevel指定的跟踪级别时,才会调用函数“fs”来生成字符串。 所以当 traceLevel 大于 Config.TraceLevel 时,它是一个无操作。根本不评估“fs”

虽然不限于,但在实践中,几乎所有用例看起来都像

Trace 4 (fun _ -> sprintf "%s : %i"  "abc" 1)

总是写“fun _ -> sprintf”部分是相当乏味的。理想情况下,最好提供一种用户可以编写的风格

Trace 4 "%s : %i" "abc" 1

它可以

  • 获取 sprintf 提供的格式/参数检查。
  • 具有与采用 lambda "fs" 的原始跟踪函数相同的性能行为。这意味着如果对跟踪级别的检查返回 false,它本质上是一个空操作。无需支付额外费用(例如字符串格式化等)

即使在阅读了原始SO question 的答案后,我也无法弄清楚如何实现这一点。

似乎 kprintf 允许针对格式化字符串调用延续函数。包装器仍然返回由 printf 函数之一返回的函数(然后可以是一个带有一个或多个参数的函数)。所以柯里化可以发挥作用。但是,在上述情况下,需要在格式化字符串之前评估条件,然后将格式化的字符串应用到 Trace.WriteLine。似乎现有的 Printf 模块有一个 API 允许注入前置条件评估。因此,通过包装现有的 API 似乎并不容易。

关于如何实现这一点的任何想法? (我很简短地阅读了FSharp.Core/printf.fs,似乎可以通过提供新的派生 PrintfEnv 来做到这一点。但是,这些是内部类型)。

更新

感谢托马斯和林肯的回答。我认为这两种方法都会对性能造成一些影响。我用 fsi 在我的机器上做了一些简单的测量。

选项 1:我原来的方法,在“假”路径上,根本不评估“fs()”。用法不是很好,因为需要编写“fun _ -> sprintf”部分。

let trace1 lvl (fs : unit -> string) =
    if lvl <= 3 then Console.WriteLine(fs())

选项 2:格式化字符串但将其丢弃在“假”路径上

let trace2 lvl fmt = 
    Printf.kprintf (fun s -> if lvl <= 3 then Console.WriteLine(s)) fmt

选项3:通过递归、反射和盒子

let rec dummyFunc (funcTy : Type) retVal =
    if FSharpType.IsFunction(funcTy) then
        let retTy = funcTy.GenericTypeArguments.[1]
        FSharpValue.MakeFunction(funcTy, (fun _ -> dummyFunc retTy retVal))
    else box retVal

let trace3 lvl (fmt : Printf.StringFormat<'t, unit>) =
    if lvl <= 3 then Printf.kprintf (fun s -> Console.WriteLine(s)) fmt
    else downcast (dummyFunc typeof<'t> ())

现在我用类似的代码对这三个时间都进行了计时

for i in 1..1000000 do
    trace1 4 (fun _ -> sprintf "%s:%i" (i.ToString()) i)

for i in 1..1000000 do
    trace2 4 "%s:%i" (i.ToString()) i

for i in 1..1000000 do
    trace3 4 "%s:%i" (i.ToString()) i

这是我得到的:

trace1: 
  Real: 00:00:00.009, CPU: 00:00:00.015, GC gen0: 2, gen1: 1, gen2: 0
trace2:
  Real: 00:00:00.709, CPU: 00:00:00.703, GC gen0: 54, gen1: 1, gen2: 0
trace3:
  Real: 00:00:50.918, CPU: 00:00:50.906, GC gen0: 431, gen1: 5, gen2: 0

因此,与选项 1(尤其是选项 3)相比,选项 2 和 3 的性能都受到了显着影响。如果字符串格式更复杂,这个差距会扩大。例如,如果我将格式和参数更改为

"%s: %i %i %i %i %i" (i.ToString()) i (i * 2) (i * 3) (i * 4) (i * 5)

我明白了

trace1: 
  Real: 00:00:00.007, CPU: 00:00:00.015, GC gen0: 3, gen1: 1, gen2: 0
trace2:
  Real: 00:00:01.912, CPU: 00:00:01.921, GC gen0: 136, gen1: 0, gen2: 0
trace3:
  Real: 00:02:10.683, CPU: 00:02:10.671, GC gen0: 1074, gen1: 14, gen2: 1

到目前为止,似乎仍然没有令人满意的解决方案来同时获得可用性和性能。

【问题讨论】:

  • 仅供参考;您可以通过添加一些记忆(即缓存)来显着改进 latkins 建议。

标签: f#


【解决方案1】:

基于@latkin 的建议,可以添加记忆功能以在一定程度上提高性能。

module Trace4 =
  let cache = 
    let d = ConcurrentDictionary<Type, obj> ()
    d.[typeof<unit>] <- box ()
    d

  let rec buildFunction (ftype : Type) : obj =
    let retTy   = ftype.GenericTypeArguments.[1]
    let retVal  = getFunction retTy
    FSharpValue.MakeFunction(ftype, (fun _ -> retVal))

  and getFunction (ftype : Type) : obj =
    cache.GetOrAdd (ftype, buildFunction)

let trace4 lvl (fmt : Printf.StringFormat<'T, unit>) =
    if lvl <= 3 then Printf.kprintf (fun s -> Console.WriteLine(s)) fmt
    else downcast Trace4.getFunction typeof<'T>

在我看来i.ToString() 增加了一些显着的开销。即使有人会扩展 Core.PrintF 以避免不必要的格式化,但仍会付出代价。

就我个人而言,我完全赞成未启用跟踪的零开销。在我工作的地方,我们有很多很多的痕迹。这些成本加起来很快,如果我们对未启用的跟踪没有零开销,则会对我们的指标产生负面影响。

【讨论】:

  • 非常感谢您分享这个想法!就个人而言,我也支持未启用跟踪的零开销。我同意 i.ToString() 之类的东西或一些更昂贵的纯日志计算会增加开销。在这种情况下,可以使用 trace1。
  • 不幸的是,即使 trace1 也不是零开销,因为它根据具体情况创建了一个需要 GC:ed 的函数对象,但我想这是方便和性能的合理组合。
  • 我也尝试使用 Reflection.Emit 生成函数值,但获得的微小性能优势并不能激发黑魔法。
【解决方案2】:

看看您的要求,在我看来,最重要的事情不是避免跟踪/记录本身, 但避免了格式化要跟踪的字符串的工作。

例如,使用System.Diagnostics.Trace 代替printf 对您没有帮助,因为是sprintf 需要时间,是吗?

因此,有几种方法可以延迟格式化。一种是使用单位函数,就像您最初所做的那样。或者,您可以使用 lazy 作为等效项。

open System

let traceUnitFn lvl (fs : unit -> string) =
    if lvl <= 3 then Console.WriteLine(fs())

let traceLazy lvl (s:Lazy<string>) =
    if lvl <= 3 then Console.WriteLine(s.Force())

对这些(在我的机器上)进行计时会得到以下结果:

printfn "traceUnitFn"
#time
for i in 1..1000000 do
    traceUnitFn  4 (fun _ -> sprintf "%s:%i" (i.ToString()) i)
#time

// traceUnitFn
// Real: 00:00:00.008, CPU: 00:00:00.000, GC gen0: 7, gen1: 0, gen2: 0

printfn "traceLazy"
#time
for i in 1..1000000 do
    traceLazy 4 <| lazy (sprintf "%s:%i" (string i) i)
#time

// traceLazy
// Real: 00:00:00.053, CPU: 00:00:00.046, GC gen0: 56, gen1: 0, gen2: 0

所以,好吧,使用lazy 会慢很多。

但是sprintf 真的是这里的瓶颈吗?让我们尝试直接调用它。

首先,我们需要为每个参数数量一个单独的函数:

let trace0Param level fmt  = 
    if level <= 3 then printfn fmt 

let trace1Param level fmt x1 = 
    if level <= 3 then printfn fmt x1

let trace2Param level fmt x1 x2 = 
    if level <= 3 then printfn fmt x1 x2 

如果我们测试这些,我们会得到:

printfn "trace0Param"
#time
for i in 1..1000000 do
    trace0Param 4 "hello"
#time

// trace0Param
// Real: 00:00:00.007, CPU: 00:00:00.000, GC gen0: 8, gen1: 0, gen2: 0

printfn "trace1Param"
#time
for i in 1..1000000 do
    trace1Param 4 "%i" i
#time

// trace1Param
// Real: 00:00:00.007, CPU: 00:00:00.000, GC gen0: 7, gen1: 0, gen2: 0

printfn "trace2Param with i.ToString"
#time
for i in 1..1000000 do
    trace2Param 4 "%s:%i" (i.ToString()) i
#time

// trace2Param with i.ToString
// Real: 00:00:00.123, CPU: 00:00:00.124, GC gen0: 25, gen1: 0, gen2: 0

前两个和你原来的一样快,所以问题都出在i.ToString() 调用上。

如果我们将字符串参数硬编码为“hello”,我们可以确认这一点:

printfn "trace2Param with hello"
#time
for i in 1..1000000 do
    trace2Param 4 "%s:%i" "hello" i
#time

// trace2Param with hello
// Real: 00:00:00.007, CPU: 00:00:00.000, GC gen0: 7, gen1: 0, gen2: 0

最后一个也一样快。并注意少了多少 GC。如果性能很关键,GC 会伤害你。

所以问题真的变成了:您在转换值以跟踪它们方面做了多少工作? 你会做很多昂贵的事情,比如i.ToString()吗?如果没有,那么你根本不需要懒惰。

最后,更重要的是,所有这些微观分析测量在断章取义时绝对没有用,任何基于它们的决定都为时过早。

例如,即使是最糟糕的实现也是每秒进行 800 万次跟踪。这真的是基于分析您的真实系统的瓶颈吗? 如果没有,那么我不会担心任何这些,只需选择最简单的实现即可。

【讨论】:

  • 我完全同意最后两段作为一般原则。但是,我没有看到实验如何证明“printf”不是瓶颈(在这些微基准测试的背景下)。您的任何跟踪函数都没有调用“printf”,因此毫不奇怪,其性能与我的“trace1”函数相似,如果您添加额外的开销,例如 (ToString()),它就会脱颖而出。但是,如果您将结果与我的“trace2”进行比较函数,你所有的跟踪函数都比它快得多。它显示了“printf”的影响(当然,在微基准测试的上下文中)。
  • 关于“最重要的不是避免跟踪/记录本身,而是避免格式化要跟踪的字符串的工作。”,我认为这不是真的。跟踪/记录本身通常比格式化字符串更昂贵。在此线程中讨论的所有示例中,都避免了跟踪/日志记录部分。避免字符串格式是进一步的附加组件。
  • F# 的 printf 是静态类型检查的,这是一个很棒的功能。这个线程的目的是试图找出是否可以在特定场景中利用这种类型检查。字符串格式化会产生开销,无论它有多小。如果它很容易实现,那么避免它总是很好的。如果需要付出很大的努力来避免它,那么是的,当然需要证明它是合理的。 (另一方面,如果我们不关心类型检查,避免字符串格式化也不难。C#反正没有格式类型检查。)
  • 所有优点!现在重读,这不是最好的答案——我深夜写的!
【解决方案3】:

这是一种方法,但是“无操作”情况需要使用反射和装箱,因此它可能比简单地格式化字符串并丢弃它要慢得多:-)

open System
open Microsoft.FSharp.Reflection

let rec dummyFunc (funcTy : Type) retVal =
    if FSharpType.IsFunction(funcTy) then
        let retTy = funcTy.GenericTypeArguments.[1]
        FSharpValue.MakeFunction(funcTy, (fun _ -> dummyFunc retTy retVal))
    else box retVal

let trace lvl (fmt : Printf.StringFormat<'t, unit>) =
    if lvl <= 3 then Printf.kprintf (fun s -> Console.WriteLine(s)) fmt
    else downcast (dummyFunc typeof<'t> ())


trace 3 "%s : %i" "abc" 1 // abc : 1
trace 4 "%s : %i" "abc" 1 // <nothing>

【讨论】:

  • 谢谢林肯。迷人的方法!我真的挠了挠头,几乎看不懂代码,:)。但是,正如您所说,它在运行时确实很昂贵。我做了一些粗略的性能测试,并将结果发布到我的问题下的“更新”部分。
【解决方案4】:

诀窍是使用kprintf 函数:

let trace level fmt = 
  Printf.kprintf (fun s -> if level > 3 then printfn "%s" s) fmt

trace 3 "Number %d" 10
trace 4 "Better number %d" 42

你可以通过部分应用来使用它,这样kprintf的格式字符串所需要的所有参数都将成为你正在定义的函数的参数。

然后该函数使用最终字符串调用延续,因此您可以决定如何处理它。

【讨论】:

  • 如果我理解正确,字符串在调用“(fun s -> if level > 3 then printfn "%s" s)" 之前仍然是格式化的。如果条件为假,目标是不格式化字符串。我还需要将 sprintf 的结果提供给 Trace.WriteLine。
  • 对不起,我错过了你问题的那一部分。您担心性能吗?
  • 正确。我们希望使用各种详细级别的跟踪来编写代码。 “Config.TraceLevel”控制运行时的跟踪级别。目标是如果条件为假,更详细的跟踪不会产生开销。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-06-24
  • 2017-12-20
  • 2020-03-11
  • 1970-01-01
相关资源
最近更新 更多