3 ポイント 投稿者 GN⁺ 2023-09-28 | 1件のコメント | WhatsAppで共有
  • 2010年に投稿されたJavaのhumanReadableByteCount回答は、2018年の研究で最もコピーされたStack Overflowコード片と確認されたが、バイトサイズのフォーマットにおける境界値で誤った結果を出していた
  • このコードは、kBMBGBのような接頭辞が1000または1024の累乗であることを利用し、ループの代わりに対数計算で単位を選ぶ方式だった
  • 中核的なバグは、SIモードで999,999 bytes"1000.0 kB"と出力される丸め境界値の問題で、仕様上、数値範囲が1から999.9なら"1.0 MB"が正しい
  • より大きな値では、double浮動小数点精度の限界も重なり、999,949,999,999,999,999という入力が1000.0 PBになっていた。補正にはしきい値計算とスケール縮小、ビットパターン補正、strictfpが必要だった
  • 最終コードは負数とLong.MIN_VALUEまで処理するが、元の簡潔さは失われた。Stack Overflowのコードをコピーする際には、エッジケースのテストと出典表示もあわせて必要になる

2010年の回答が狙った単純化

  • 問題は、バイト数を人間が読みやすい文字列にフォーマットすることだった
    • 例: 123,456,789 bytes"123.5 MB"のように出力する
    • 暗黙の仕様は、結果文字列の数値部分が1から999.9の間で、適切なサイズ接尾辞が付く形式だった
  • 既存の回答は、EBPBTBGBMBkBBを大きい単位から順にたどり、バイト数より小さい最初の単位を選ぶループベースのアプローチだった
  • 新しい回答は、ループと分岐を減らすためにMath.logMath.powを使っていた
    • SIモードでは単位が1000
    • バイナリ表記では単位が1024
    • exp = log(bytes) / log(unit)の値を整数に変換し、接頭辞インデックスとして使用
    • 接頭辞はSIでは"kMGTPE"、バイナリでは"KMGTPE"を使い、バイナリには"i"を付け加える

コピーの実態とOpenJDKでのエピソード

  • Sebastian Baltesの論文Usage and Attribution of Stack Overflow Code Snippets in GitHub Projectsは、Stack Overflowのコード片がGitHubプロジェクトでどのように使われ、出典表示されているかを分析した
  • 分析方法は、Stack Overflowのデータダンプからコード片を抽出し、公開GitHubリポジトリのコードと照合するものだった
    • 中心的な問いは、Stack OverflowのCC BY-SA 3.0ライセンスに沿った出典表示が守られているかだった
    • 結果として、ほとんどの利用者は適切な出典表示を含めていなかった
  • 回答ID 3758880は論文の表で最上位にあり、当時は数十万回の閲覧と1,000件以上のアップボートを得ていた
  • GitHubでhumanReadableByteCountを検索すると数千件の使用例が見つかり、ローカルリポジトリでは次のコマンドで確認できる
git grep humanReadableByteCount
  • OpenJDKリポジトリでも一致例が見つかった
    • そのコードには出典表示がなく、OpenJDKのライセンスはCC BY-SA 3.0と互換性がなかった
    • Sebastian BaltesはOpenJDK開発メーリングリストで、そのコードがStack OverflowからOpenJDKへコピーされたのか、逆だったのかを質問した
    • 回答の投稿者は、そのコミットがマージされる前にはOracleに入社しておらず、そのパッチにも貢献していなかった
    • その後、課題が登録され、コードは削除された

1つ目のバグ: 9が続く境界値

  • 一見疑わしい問題は、実際の原因ではなかった
    • longの最大値は2^63 - 1、約9.2 × 10^18なので、EBより上の単位には進まない
    • bytes < unitの場合は最初のifが処理するため、expが0になってcharAt(exp - 1)が失敗することもない
  • 実際の問題は丸め境界値だった
    • 入力999,999 bytesはSIモードで"1000.0 kB"になる
    • 数値部分が1から999.9の間でなければならないという仕様に従えば、正しい結果は"1.0 MB"である
  • 執筆時点で投稿されていた22件の回答すべてが、Apache CommonsやAndroidライブラリを使う回答も含め、このバグまたはその変種を抱えていた
  • 解決の要点は、指数expをいつ次の単位に上げるかを決めるしきい値である
    • kからMに変わる時点は、値が999.9 kより1 MBに近くなる999,950
    • MからGに変わる時点は999,950,000
    • バイナリモードではしきい値が整数ではないため、ceilが必要になる
if (bytes >= Math.ceil(Math.pow(unit, exp) * (unit - 0.05)))
    exp++;

2つ目のバグ: double精度の限界

  • 上の補正を適用しても、999,949,999,999,999,999という入力は1000.0 PBと出力され、正しい結果は999.9 PBだった
  • 原因は数式そのものではなく、double精度の限界だった
    • IEEE 754表現では、0に近い浮動小数点値は密だが、大きな値は非常に疎になる
    • 非常に大きなdoubleでは、Long.MAX_VALUEを引いても値が変わらないことがある
double a = Double.MAX_VALUE;
double b = a - Long.MAX_VALUE;
System.err.println(a == b); // prints true
  • 問題となる計算は2か所で発生する
    • String.formatの引数で行う除算
    • expを上げるかどうかを決めるしきい値計算
  • 1つ目の問題は、中間のbytes値を精度がより良い範囲に下げ、expを調整する方式で処理する
    • 最終結果はいずれ丸められるため、下位桁を捨ててもよいという前提である
if (exp > 4) {
    bytes /= unit;
    exp--;
}
  • 2つ目の問題では下位ビットが重要だった
    • 999,949,99…9999,950,00…0は異なる指数に分類されなければならない
    • 可能なしきい値はSIとバイナリを合わせて12個あり、そのうち1つだけが誤った結果を出していた
    • 誤った結果はD00で終わるビットパターンで識別して補正する
    • 特定の浮動小数点結果のビットパターンに依存するため、strictfpを付けた

負数入力と最終コード

  • Javaにはunsigned longがないため、負のバイト数の処理も追加された
    • 従来は-10,000という入力が-10000 Bと出力されていた
    • absBytesを導入し、exp関連の計算は絶対値基準で行う
  • Long.MIN_VALUEには特別な処理が必要だった
    • -Long.MIN_VALUE == Long.MIN_VALUEだからである
    • そのため、bytes == Long.MIN_VALUEならLong.MAX_VALUEを使い、それ以外ではMath.abs(bytes)を使う
  • 最終版には、strictfp、しきい値補正、Long.MIN_VALUE処理、大きな指数でのスケール縮小が含まれる
  • ループと過剰な分岐を避けようとしたコードは、すべてのコーナーケースを整えた後、元のバージョンより読みにくいコードになった
  • プロダクション品質の最新コードは、別記事Formatting byte size to human readable formatを参照できる

実務で残る教訓

  • Stack Overflowのコード片は、数千件のアップボートがあってもバグを含む可能性がある
  • コピーしたコードには、とくにエッジケースのテストが必要である
  • 浮動小数点演算は、境界値と大きな数で扱いが難しい
  • コードをコピーするときは適切な出典表示が必要であり、そうしないと実際に問題になり得る

1件のコメント

 
GN⁺ 2023-09-28
Hacker News の意見
  • ハードコードされた値と if 文(または while)を使う回答が、どれも最大 5回の比較を行うのは興味深い
    単位が B、KiB、MiB、GiB、TiB、EiB までだけなら、最大3つの if 文でも解決できる。GiB 以上かを確認すれば B/KiB/MiB ではないと分かるので、二分探索が勝つ
    ZiB と YiB まで増やしても最大3回の比較で十分で、ハードコード方式は最大7回まで行く。自分で書くなら log/pow/浮動小数点はミスの可能性が大きすぎるので使わず、if 文をハードコードしつつ二分探索にすると思う

    • 二分探索方式は単純に 6回検査するより遅いかもしれない。後者は分岐を1つしか通らない可能性が高く、分岐は非常に遅いので、コードはできるだけ直線的な流れにしておくのがよい
    • 入力の分布による。小さい値が非常に多いなら、線形探索の方がよい場合もある
    • これはひどいエンジニアリング判断だと思う。単純な解法は同僚が簡単にレビューでき、境界条件も明確に見え、テストがそれをカバーしているか確認しやすい
      このようなコードは、より遅く、より複雑で、テストやレビューもより難しいコードを書くために多くのことをしているようなものだ
  • (2019)過去の議論:
    https://news.ycombinator.com/item?id=21693431
    https://news.ycombinator.com/item?id=21698619
    https://news.ycombinator.com/item?id=27533684

  • 理解できない。接尾辞が7つなら二分探索で正しいものを選べばよく、比較は3回で済む。あるいは単純にやっても比較は6回だ
    log() を2回、pow() を1回、ceil() を使うのが、単純な方式よりなぜ良いというのか分からない。ここで説明されているバグ自体が、賢くやりすぎようとして生まれた完璧な例だ

    • 作者は可読性が落ちることを認め、再びループを使う方式に戻したようだ: https://programming.guide/java/formatting-byte-size-to-human...
      それでも丸めのバグを考慮しているので、原文の最初のコード例よりは少しましだ
    • 作者も最初に、ループより実際に優れているわけではないと言っている
      また比較6回は最大値の場合だけで、実際の使用ではそうなる可能性は低そうだ。ほとんどの値が B や KB の範囲なら、線形方式の方がよい場合もある
  • 厚かましい宣伝だが、S/O からコピーする代わりに、人間が読みやすい形式でサイズを高速かつ正確にフォーマットしたいなら、私たちのオープンソース PrettySize ライブラリも使える。Rust 用 [0] と .NET 用 [1] があり、ファイルサイズに対する型安全な論理演算も安全かつ簡単にしてくれる
    S/O のスニペットは4行だが、これらのライブラリははるかに包括的で、テスト、出力フォーマットオプション、サイズ変換などを含む
    [0]: https://github.com/neosmart/prettysize-rs
    [1]: https://github.com/neosmart/PrettySize.net

    • 4行の解法を巨大なライブラリで置き換える文化が left-pad を生んだ
  • 純粋な疑問なのだが、Stack Overflow の信用できないコードをそのままコピーしてアプリケーションに貼り付ける開発者はかなり多いのだろうか?
    人々が Stack Overflow からそのままコピーするという推測は有名だが、実際に誰かがやっているのを見るまでは冗談に近いと思っていた。自分もなじみのない領域で問題を解くときの出発点として Stack Overflow を使うが、コードをそのままコピーしたことはない。
    たいてい断片的なコードは、自分が必要としていることだけを正確にやってくれるわけではないので、API を調べ、説明されているアプローチをもとに自分の解法を作る必要がある。特に Python では、Stack Overflow が有用なニッチな APIの方向を示してくれたことが多い。

    • 以前、答えを見た瞬間にコードへコピーするのを誰にも止められない開発者と仕事をしたことがある。自分が遭遇している問題と同じか確認するために質問すら読まず、回答も読まなかった。
      文字どおり Google → 最初に見える Stack Overflow のリンクをクリック → 最初に見えるコードブロックをコピー&ペースト、という流れで、時には言語すら違っていた。ペアプログラミング中には、入力デバイスを物理的に奪い取る必要があった。間違っていると言うと、こちらが言い終える前にページ上の 2 つ目のコード片を貼り付けていて、妙に速かった。
      極端な例ではあるが、「コードが必要だ。Stack Overflow にコードがある。解決!」という考え方で、それが適切な解法かどうかをまったく考えない開発者は多い。
    • 実際にそういうことはあるし、自分が関心を持っているプログラム部分の範囲外のように感じるものほど、より頻繁に起こる。
      いずれにせよ私たちは、あまり気にしていない配管作業の部分について、見知らぬ人が作ったライブラリコードを常に取り込んでいる。掘り下げて理解したければ自分で書く可能性が高いが、この部分は「とにかく動く」状態にしてプロジェクトを進めたいなら、コンパイラエラー駆動開発になる。
    • 筆者が述べている理由から、ほぼそのままコピー&ペーストすることはしない。代わりに解法を理解しようとし、必要なら 1 行ずつ手で書き写し、きちんと理解してからそこを起点にリファクタリングする。
      変数名も変える。foobarbaz が多すぎて、人間には読みづらいことが多いからだ。同じ問題に再び出会ったときも、盲目的にコピーした場合より、自分が何をしたのか思い出しやすい。
    • 実際に人々はそうしている。Stack Overflow の誤ったTLS コードと設定をとてつもなく多く見てきたので、ほとんどのシステムは証明書を適切に検証しないまま動いているのだろうと、かなり確信するようになった。
    • おそらく、Adderall を飲んだ 23 歳たちが作ったコードベースで働く楽しみを、まだ味わったことがないのだろう。
  • log 2 が必要なのに、なぜ浮動小数点のログを使うのか分からない。
    私が何か見落としていなければ、以下の式は 2^63 バイト未満の正の数について floor(log2(value)) を正確に返し、しかもずっと速い。
    Long.bitCount( (Long.highestOneBit(value) << 1) - 1) - 1

    • 「一般的な」単位は10 の累乗なので、この方法は正しくない。
  • コード片を見た瞬間に、浮動小数点の log 演算と整数に対する除算が目に入り、賢く書きすぎたせいで本質的にバグを生みやすいコードだと判断して、頭の中ですぐに捨てた。

    • それが事実上、記事の要点だ。
  • 知識の連鎖はどこまでも続く。ごく小さな知識でさえ、一度取り出すと元に戻すのがどれほど難しいかを示している。
    Stack Exchange が活発な貢献者を急速に失っている状況で、後になって誤りだったと判明した早撃ち回答を正すには何が必要なのか気になる。そして、こうした「少し間違った」回答が検索履歴に、さらに LLM の歴史の中にますます固定化されていくと、私たちの集合知にとって何を意味するのかも気になる。

  • 基礎軍事訓練のときを思い出す。教官たちは新兵に、誰もやり方を知らない課題をわざと指示なしで与えて立ち去ることがあった。
    すると必ず誰かが間違った方法で始め、残り全員がその人に従った。

    • 他人より悪く見られたくないという人間の性向が、こういうことをさらに悪化させるのか気になる。賢い人たちでさえ、悪いアイデアや性急なアイデアに従うという愚かな結果につながりうる。
      公開される経済見通しでも似たようなことが起こる。他の人は当てたのに一人だけ外した人は、全員が一緒に外した人よりはるかに厳しく扱われる。
    • その訓練の目的は何だったの?
  • こうしたアルゴリズムにおける浮動小数点誤差を、必ずしも「欠陥」とは見なさない。コードが論理的かつ数学的に正しい解法を定義しているなら、それ自体は「正しい」と考える。
    浮動小数点誤差を解決するのはそれより一段上の作業であり、実際に重要なときにだけ行うものだ。浮動小数点誤差が存在せず、考慮する必要もない完璧な未来のプログラミング言語を想像できるが、私のアルゴリズムの 99% はそういう言語を対象にしているようなものだ。