如何安全地将秒转换为纳秒,避免整数/浮点数溢出?

4
  • 我目前有一个UNIX时间戳,它是一个64位浮点数。它以浮点数的形式表示秒数和一些小数部分,例如1687976937.597064
  • 我需要将其转换为纳秒。但是,1秒钟有10亿纳秒。直接乘以10亿会导致64位浮点数溢出。

让我们首先考虑一下限制:

  • 1_687_976_937_597_064_000 是上述时间戳乘以十亿的整数结果。目标是找到一种安全达到这个数字的方法。
  • 9_223_372_036_854_775_807 是一个64位有符号整数可以存储的最大数字。
  • 9_007_199_254_740_992.0 是一个64位浮点数可以存储的最大数字。在这个范围内,没有足够的位来存储任何小数(它永远是.0)。编辑:这个说法是不正确的。请参阅本帖末尾...
  • 因此,64位有符号整数可以容纳结果。但是64位浮点数无法容纳结果,会溢出。

所以我在思考:

  • 由于整数能够轻松表示结果,我想先将整数部分转换为整数,然后乘以十亿。
  • 然后提取小数部分,得到一个新的0.XXXXXX浮点数,然后将其乘以十亿。通过在前面加上零,确保浮点数的整数部分永远不会溢出。但是或许小数部分仍然可能溢出吗?希望浮点数只会安全地截断尾部的小数而不会溢出。通过将0.X数乘以十亿,得到的结果应该永远不会超过1_999_999_999.XXXXX,所以这个乘法看起来应该是安全的...
  • 之后,我将"小数浮点数"截断为整数,以确保结果是一个整数。
  • 最后,我将两个整数相加。

看起来似乎可以工作,但这种技巧看起来很繁琐。它是否安全?

以下是一个展示这个过程的Python repl:

>>> num = 1687976937.597064
>>> whole = int(num)
>>> whole
1687976937
>>> decimals = num - whole
>>> decimals
0.5970640182495117
>>> (whole * 1_000_000_000)
1687976937000000000
>>> (decimals * 1_000_000_000)
597064018.2495117
>>> int(decimals * 1_000_000_000)
597064018
>>> (whole * 1_000_000_000) + int(decimals * 1_000_000_000)
1687976937597064018
>>> type((whole * 1_000_000_000) + int(decimals * 1_000_000_000))
<class 'int'>

所以这是比较的结果:
- `1_687_976_937_597_064_018` 是上述算法的结果。是的,有一个微小的、无关紧要的浮点舍入误差,但我不介意。 - `1_687_976_937_597_064_000` 是 Wolfram Alpha 计算器给出的科学正确答案。
看起来确实很成功,但我的算法是否存在危险并可能出错呢?
我没有足够的勇气在没有确认安全性的情况下将其投入生产。
关于64位浮点数的限制:以下是Python 3的repl中的结果(请注意输入中的993和输出中的992):
>>> 9_007_199_254_740_993.0
9007199254740992.0

但也许我对“限制”这个词的理解有误...也许这只是一个浮点数四舍五入的错误。

5
乘以十亿不会导致64位浮点数溢出,也不会丢失精度。记住,这是浮点数。它会进行乘法运算,指数会增加,但有效位数应该保持不变。 - Tom Karzes
5
乘以十亿不会导致64位浮点数溢出,也不会丢失精度。记住,这是浮点数。它会进行乘法运算,指数会增加,但有效位数应该保持不变。 - Tom Karzes
5
乘以十亿不会导致64位浮点数溢出,也不会丢失精度。记住,这是浮点数。它会进行乘法运算,指数会增加,但有效位数应该保持不变。 - undefined
4
“会溢出64位浮点数” 这是不正确的。double 的范围大约是10^369。宇宙的年龄以纳秒计算大约是10^27。“9_007_199_254_740_992.0 是可以存储在64位浮点数中的最大数字” 这是不正确的。 - n. m. will see y&#39;all on Reddit
4
"会超过64位浮点数的范围" 这是不会的。 double 的范围大约是10的369次方。宇宙的年龄以纳秒计约为10的27次方。 "9_007_199_254_740_992.0 是在64位浮点数中可存储的最大数字" 这是不正确的。 - n. m. could be an AI
显示剩余39条评论
3个回答

1

与64位浮点数一起工作的问题是"可信"数字的数量为15。也许第16位数字也是正确的,但你无法知晓。

因此,当你提取(或打印)浮点数的小数部分时,第16位数字之后的所有内容都是垃圾。

你说你需要C POSIX utimensat。它需要一个包含timespec结构体的数组,其中有两个成员用于保存秒数和纳秒数。

第一个成员(秒数)可以使用任何整数类型,可能是"long"。但用于纳秒数的类型("long")依赖于平台:它可以是32位或64位有符号整数。
因此,必须注意可用范围。

你可以在C文档中查看类型的限制

幸运的是,一个32位长整型可以容纳9个数字(以及开头的额外的0、1、2)。这足够打印纳秒部分(正好9个数字)。

到目前为止,一切都很好。你可以使用32位整数,不需要64位。

现在关键是如何将浮点数拆分成两个整数。
一种方法是将数字转换为字符串,并为小数部分添加所需的零。然后取小数点之前和之后的部分,将它们转换为整数,就完成了。

没有字符串,整数部分很容易。一个简单的int(number)
对于小数部分,你可以用num-whole进行减法。然后乘以10^9,转换为整数并截断到正确的位置。这个位置恰好在第9位数字之后。


这是一个很好的回答,所以我给它点了赞,尽管不幸的是,它基于我没有足够准确的描述:是的,我正在使用上述的utimensat API,但我是通过Python的包装器进行的:os.utime(),它接受两个64位有符号整数:"(atime_ns, mtime_ns),其中每个成员都是表示纳秒的整数。"对于我不准确的评论造成的困惑,我真诚道歉。我认为你的回答非常周到和考虑周全,所以我能做的最少就是给它点赞。对于它旨在解决的问题来说,这是一个很棒的答案。 :) - Mitch McMabers
这是一个很好的回答,所以我给它点了赞,尽管不幸的是,它是基于我没有足够准确的描述:是的,我正在使用上述的utimensat API,但我是通过Python的包装器来实现的:os.utime(),它接受两个64位有符号整数:“(atime_ns, mtime_ns),其中每个成员都是表示纳秒的整数。”对于我不准确的评论造成的混淆,我真的很抱歉。我认为你的回答思路很清晰,考虑周到,所以我至少可以做的就是给它点赞。对于它所针对的问题,这是一个很好的回答。 :) - Mitch McMabers
这是一个很好的回答,所以我给它点了赞,尽管不幸的是,它是基于我没有足够准确的描述:是的,我正在使用上述的utimensat API,但我是通过Python的包装器来实现的:os.utime(),它接受两个64位有符号整数:"(atime_ns, mtime_ns),其中每个成员都是表示纳秒的整数。对于我不准确的评论所造成的困惑,我真的很抱歉。我认为你的回答思路很清晰,考虑周到,所以我能做的最少就是给它点赞。对于它所针对的问题,这是一个很好的回答。 :) - undefined

1

对于文件,在Unix系统中,时间戳的准确性取决于底层操作系统的时间戳精度。这个精度可以是2秒、1秒、毫秒、微秒和纳秒。如果你将超过操作系统精度的数字进行转换(如果有的话),那么你将得到一个不正确的纳秒表示。

此外,64位浮点数可以准确表示范围在2^53的整数。潜在地,纳秒值可能是64位的,因此并非所有值都可以表示。通常假设的是64位浮点数的十进制表示对于15位小数数字是准确的(它可以更多,但15位小数数字将容纳所有数字组合,无论是否是2的因数)。

解决这些问题的两种方法。假设使用毫秒文件时间戳,或者使用1e-3

您可以使用字符串来获得您期望的四舍五入值:

def ts2its(ts):
    sec,_,dec=f'{ts:.3f}'.partition('.') # use locale to get the decimal...
    return int(sec)*1_000_000_000+int(dec)*1_000_000

>>> ts2its(1687976937.597064)
1687976937597000000

或者,如果你想采用纯数学方法,你可以按照以下方式使用math.modf来舍弃毫秒后的数字:
def ts2its2(ts):
    # reliable in all locales and platforms
    sec1, dec = math.modf(ts)
    _,sec = math.modf(sec1*1e3)
    return int(dec*1e9)+int(sec*1e6)

>>> ts2its2(1687976937.597064)
1687976937597000000

你在自己的回答中提出了这个解决方案。
>>> ts=1687976937.597064
>>> int(ts*1_000_000_000)
1687976937597063936

虽然99%的时间这是一个很好的解决方案。但是让我们来谈谈那1%的时候它不是。
首先,让我们看一下你的例子时间:
 1687976937.597064
 SSSSSSSSSS          Significant under 1 second granularity
 SSSSSSSSSS SSS      Significant under millisecond granularity
 SSSSSSSSSS SSSSS?   The 16th digit is questionable as accurate

 S=significant under 

现在看一下 int(ts*1_000_000_000) 的结果:
 1687976937597063936
 SSSSSSSSSSSSSSS?XXX

 S=significant
 ?=Questionably significant
 X=Likely inaccurate.

所以问题实际上与你的问题陈述相反。
使用64位浮点数:
1. 任何时间戳都可以精确到毫秒; 2. 大多数时间戳可以精确到微秒; 3. 在微秒和纳秒时间戳之间,逐位精度会丢失。
64位浮点数时间戳实际上只是一个方便的数据结构。纳秒时间戳的实际表示取决于操作系统,但通常是一个由两个整数组成的复合数据结构;一个表示秒数,另一个表示小数部分。

看到它以这种方式完成真是太有趣了。还有一个内置的库可以完全做到这一点:https://docs.python.org/3/library/decimal.html。如果我选择“将其视为字符串”的方法,我肯定会更喜欢使用官方库,以确保没有错误和陷阱(例如,你在示例中提到你的格式化字符串是与地区相关的,并且可能会出现问题)。虽然这是一个很酷的尝试,但我肯定会选择使用 decimal 库。:D - Mitch McMabers
1
@MitchMcMabers: 令人讨厌的是,它返回的“整数”部分实际上是一个浮点数,而不是整数,因此在小数点前的有效部分仍然会有浮点舍入误差。 这实际上是不正确的。64位浮点数可以精确地表示范围为2^53的整数。请参见这里。因此,从毫秒到纳秒的转换只会在第16位小数后产生误差,或者如果您认为1e-3之后的数字是有意义的... - dawg
1
@MitchMcMabers: 令人讨厌的是,它返回的“整数”部分实际上是浮点数,而不是整数,因此在小数点前的有效部分仍然会有浮点数舍入误差。 实际上这并不准确。64位浮点数可以精确地表示2^53范围内的整数。请参考这里。因此,从毫秒到纳秒的转换只会在小数点后的第16位数字之后产生误差,或者如果您认为1e-3之后的数字是有意义的话... - dawg
谢谢你更新答案的版本。我刚刚看到了。是的,“直接浮点数相乘”是最不准确的方法。我在我的答案中用例子提到了这一点,并且也提到了更准确的方法(将整秒作为整数相乘,将纳秒作为独立的浮点数相乘)。问题是,在第一个或者前两个小数点之后,没有人真的关心时间戳的准确性。18.25秒实际上和18.25239382秒差不多。这就是为什么我首先提到了简化的变体。但是更准确的变体也很快,正如我在基准测试中所看到的。 :) - Mitch McMabers
@MitchMcMabers: 问题是,在前1或2个小数位之后,没有人真的关心时间戳的准确性。 这就是我们出现Y2K和Y38问题的原因。准确性确实很重要,尤其是在相对容易实现的情况下。在高交易量的情况下,只有1或2位小数位(精度小于毫秒)的情况下,许多交易将失去它们的逻辑创建、读取和修改时间。这可能会导致问题。 - dawg
显示剩余15条评论

0
浮点数可以具有非常大的指数,而不会失去其显著的精度。事实证明,浮点数允许进行非常大的乘法运算而没有任何问题,如下所示:
>>> 9_000_000_000_000_000_000_000_000_000.0 * 1_000_000_000
9e+36
>>> 9_123_456_789_012_345_678_901_234_567.0 * 1_000_000_000
9.123456789012346e+36
>>> int(9_123_456_789_012_345_678_901_234_567.0 * 1_000_000_000)
9123456789012346228226434616254267392

所以基本上,浮点数会尽可能保留内部可以容纳的“有效数字”,截断剩余部分(在上面的示例中是左手操作符),然后只是调整指数。它能够粗略表示远远超过宇宙年龄的Unix纳秒时间戳。
当转换为整数时,你也可以看到浮点数保留了尽可能多的精度,并且在转换方面表现出色。所有的有效数字都在那里。输出数字末尾有很多“随机浮点舍入误差/噪音”,但这些数字并不重要。
换句话说,我对浮点数可以存储的数字大小有一个根本性的误解。它并没有固定限制。它只存储了一定数量的有效数字,然后使用指数来达到所需的比例。所以在这里使用浮点数就足够了!
答案是我可以直接进行乘法运算,完全安全。由于我的乘数是纯粹的十亿,没有任何小数部分,它只会将指数放大十亿倍,而不改变任何数字。太棒了。 :)
就像这样!
>>> int(1687976937.597064 * 1_000_000_000)
1687976937597063936

尽管我们在上面使用整数,但Python实际上会将其内部转换为浮点数(1_000_000_000(int)-> 1e9(float)),因为另一个操作数是浮点数。
因此,直接使用浮点数进行乘法运算实际上比先将乘数转换为浮点数再进行乘法运算要快6%。
>>> int(1687976937.597064 * 1e9)
1687976937597063936

如你所见,结果是相同的,因为两种情况都进行了float * float的数学运算。整数只是需要额外的转换步骤,而后一种方法避免了这一步骤。
让我们回顾一下:
  • 1_687_976_937_597_064_018是我之前在原问题中使用“分割”算法得到的结果。
  • 1_687_976_937_597_063_936是根据建议“只信任浮点数并直接进行乘法运算”得到的结果。
  • 1_687_976_937_597_064_000是Wolfram Alpha计算器给出的数学上正确的答案。
所以我的“分割”技术具有更小的舍入误差。我的方法更准确的原因是因为我将数字分割成了“整数”(int)和“小数/分数”(float)。这意味着我的方法将所有有效数字完全用于小数部分,因为我在小数/分数之前去除了“整数部分”。这意味着我的“小数”浮点数能够将所有有效数字用于更精确地表示小数部分。

但这些是表示为纳秒的UNIX时间戳,没有人真的关心“秒的小数精度”那么多。重要的是小数部分的前几位数字,而这些数字都是正确的。这才是最重要的。我将使用这个结果通过utimensat API在磁盘上设置时间戳,真正重要的是我能获得大致正确的秒的小数部分。:)

我使用Python中的os.utime()封装器来调用该API,它将纳秒作为有符号整数处理:“如果指定了ns,它必须是一个形如(atime_ns, mtime_ns)的2元组,其中每个成员都是表示纳秒的整数。”

我将进行直接乘法运算,然后将结果转换为整数。这样一步简单的计算就可以获得足够精确的小数部分(秒的分数),并以令人满意的方式解决了问题!

这是我将要使用的Python代码。它通过从磁盘获取该值来保留当前的“访问时间”,并将self.unix_mtime浮点数(UNIX时间戳,小数部分表示秒的小数)转换为有符号64位整数纳秒表示,然后将更改应用到目标文件/目录中。
# Good enough precision for practically anybody. Fast.
file_meta = target_path.lstat()
st_mtime_ns = int(self.unix_mtime * 1e9)
os.utime(
    target_path, ns=(file_meta.st_atime_ns, st_mtime_ns), follow_symlinks=False
)

如果有其他人想要这样做,请注意我正在使用lstat()来获取符号链接的状态而不是它们的目标,并且使用follow_symlinks=False来确保如果最终的target_path组件是一个符号链接,则影响的是链接本身而不是目标。如果您希望影响目标而不是符号链接本身,其他人可能希望将这些调用更改为stat()follow_symlinks=True。但我猜测,大多数人喜欢采用我的方法,如果target_path指向一个符号链接,那么会影响符号链接本身。

如果您关心以最高可达精度进行此"秒浮点数到纳秒整数"转换(通过将最大浮点精度专用于所有小数位数以最小化舍入误差),则可以使用我的"分割"变体如下进行(我添加了类型提示以增加清晰度):

# Great conversion precision. Slower.
file_meta = target_path.lstat()
whole: int = int(self.unix_mtime)
frac: float = self.unix_mtime - whole
st_mtime_ns: int = whole * 1_000_000_000 + int(frac * 1e9)
os.utime(
    target_path, ns=(file_meta.st_atime_ns, st_mtime_ns), follow_symlinks=False
)

如您所见,它使用 int * int 进行"整秒"的计算,并使用 float * float 进行"秒数的小数部分"的计算。然后将结果组合成一个整数。这在准确性和速度方面兼顾了两者的优点。

我进行了一些基准测试:

  • 在 Ryzen 3900x CPU 上进行了 5000 万次迭代。
  • "简化、较不准确" 版本花费了 11.728529000014532 秒。
  • 更准确的版本花费了 26.941824199981056 秒。这是时间的 2.3 倍。
  • 考虑到我进行了 5000 万次迭代,你可以放心地使用更准确的版本,而无需担心性能问题。所以,如果你想要更准确的时间戳,请随意使用最后一种方法。 :)
  • 作为额外奖励,我对 @dawg 的答案进行了基准测试,该答案与"更准确的方法"完全相同,但是通过两次调用 math.modf() 来完成,而不是直接手动计算整数和小数部分。他们的答案是最慢的,需要 33.54755139999557 秒。我不推荐使用它。此外,他们技术背后的主要思想只是舍弃小数点后的前三位小数,这对于任何实际目的都没有影响,如果真的希望删除它们,可以通过将我的"更准确"变体的最后一行改为whole * 1_000_000_000 + (int(frac * 1e3) * 1_000_000)来实现,而不需要慢速的 math.modf() 调用,这样可以在 27.95227960000746 秒内完成小数部分截断技术。
还有第三种方法,通过讨论的decimal库,它具有完美的数学精确度(不使用浮点数),但很慢,所以我没有包含它。

1
此外,使用float*int_literal可能会更慢,因为int_literal通常会被转换为浮点数(除非编译器注意到可以通过位移来实现乘法)。请参考这里 - dawg
1
此外,使用float*int_literal可能实际上会更慢,因为整数常量通常会被转换为浮点数(除非编译器注意到可以通过位移来完成乘法)。请参见此处 - dawg
1
@dawg 你说得对。我多年前了解到“浮点数乘法和除法非常慢”的小知识,但事实证明Python并不是这样工作的。Python将1_000_000_000整数转换为浮点数。所以它与使用1e9写法相同,只是在乘法进行之前需要进行额外的转换步骤。我进行了基准测试,使用整数比使用浮点数要慢约6%。虽然两种方法都非常快。嘛,谢谢你的发现。我正在编辑答案,改用1e9浮点数代替。:) 我很庆幸今天学到了有关浮点数计算的新知识。 - Mitch McMabers
1
@dawg 你说得对。我多年前了解到“浮点数的乘除运算非常慢”,但事实证明Python并不是这样工作的。Python将1_000_000_000整数转换为浮点数。所以它与写1e9是一样的,只是在进行乘法之前需要进行额外的转换步骤。我进行了基准测试,使用整数比使用浮点数要慢大约6%。虽然两种方法都非常快。不过无所谓了,谢谢你的发现。我正在编辑答案,改用1e9浮点数。 :) 很高兴今天学到了有关浮点数计算的新知识。 - Mitch McMabers
1
@dawg 你说得对。我多年前就听说过“浮点数乘法和除法非常慢”的小知识,但事实证明Python并不是这样工作的。Python将1_000_000_000整数转换为浮点数。所以这与写1e9是一样的,只是在进行乘法之前多了一步转换。我进行了基准测试,使用整数比使用浮点数慢了约6%。虽然两种方法都非常快。嗯。感谢你的发现。我正在编辑答案,改用1e9浮点数。:) 今天我很庆幸学到了有关浮点数运算的新知识。 - undefined
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接