diff --git a/spec/std/benchmark_spec.cr b/spec/std/benchmark_spec.cr index 5c91500ce347..c49b02322b0e 100644 --- a/spec/std/benchmark_spec.cr +++ b/spec/std/benchmark_spec.cr @@ -68,8 +68,8 @@ private def h_mean(mean) end describe Benchmark::IPS::Entry, "#human_mean" do - it { h_mean(0.01234567890123).should eq(" 0.01 ") } - it { h_mean(0.12345678901234).should eq(" 0.12 ") } + it { h_mean(0.01234567890123).should eq(" 12.35m") } + it { h_mean(0.12345678901234).should eq("123.46m") } it { h_mean(1.23456789012345).should eq(" 1.23 ") } it { h_mean(12.3456789012345).should eq(" 12.35 ") } @@ -94,7 +94,7 @@ private def h_ips(seconds) end describe Benchmark::IPS::Entry, "#human_iteration_time" do - it { h_ips(1234.567_890_123).should eq("1234.57s ") } + it { h_ips(1234.567_890_123).should eq("1,234.57s ") } it { h_ips(123.456_789_012_3).should eq("123.46s ") } it { h_ips(12.345_678_901_23).should eq(" 12.35s ") } it { h_ips(1.234_567_890_123).should eq(" 1.23s ") } diff --git a/spec/std/big/big_int_spec.cr b/spec/std/big/big_int_spec.cr index bf915f5b5920..1a5daaded625 100644 --- a/spec/std/big/big_int_spec.cr +++ b/spec/std/big/big_int_spec.cr @@ -339,6 +339,11 @@ describe "BigInt" do x = 1.to_big_i x.clone.should eq(x) end + + describe "#humanize_bytes" do + it { BigInt.new("1180591620717411303424").humanize_bytes.should eq("1.0ZiB") } + it { BigInt.new("1208925819614629174706176").humanize_bytes.should eq("1.0YiB") } + end end describe "BigInt Math" do diff --git a/spec/std/http/server/handlers/log_handler_spec.cr b/spec/std/http/server/handlers/log_handler_spec.cr index 368e5055aa50..72131bf53a05 100644 --- a/spec/std/http/server/handlers/log_handler_spec.cr +++ b/spec/std/http/server/handlers/log_handler_spec.cr @@ -13,7 +13,7 @@ describe HTTP::LogHandler do handler = HTTP::LogHandler.new(log_io) handler.next = ->(ctx : HTTP::Server::Context) { called = true } handler.call(context) - (log_io.to_s =~ %r(GET / - 200 \(\d.+\))).should be_truthy + log_io.to_s.should match %r(GET / - 200 \(\d+(\.\d+)?[mµn]s\)) called.should be_true end diff --git a/spec/std/humanize_spec.cr b/spec/std/humanize_spec.cr new file mode 100644 index 000000000000..4c887880e47f --- /dev/null +++ b/spec/std/humanize_spec.cr @@ -0,0 +1,111 @@ +require "spec" + +private LENGTH_UNITS = ->(magnitude : Int32, number : Float64) do + case magnitude + when -2, -1 then {-2, " cm"} + when .>=(4) + {3, " km"} + else + magnitude = Number.prefix_index(magnitude) + {magnitude, " #{Number.si_prefix(magnitude)}m"} + end +end + +describe Number do + describe "#format" do + it do + 1.format.should eq "1" + 12.format.should eq "12" + 123.format.should eq "123" + 1234.format.should eq "1,234" + + 123.45.format.should eq "123.45" + 123.45.format(separator: ',').should eq "123,45" + 123.45.format(decimal_places: 3).should eq "123.450" + 123.45.format(decimal_places: 3, only_significant: true).should eq "123.45" + 123.4567.format(decimal_places: 3).should eq "123.457" + + 123_456.format.should eq "123,456" + 123_456.format(delimiter: '.').should eq "123.456" + + 123_456.789.format.should eq "123,456.789" + end + end + + describe "#humanize" do + it { 0.humanize.should eq "0.0" } + it { 1.humanize.should eq "1.0" } + it { -1.humanize.should eq "-1.0" } + it { 123.humanize.should eq "123" } + it { 123.humanize(2).should eq "120" } + it { 1234.humanize.should eq "1.23k" } + it { 12_345.humanize.should eq "12.3k" } + it { 12_345.humanize(2).should eq "12k" } + it { 1_234_567.humanize.should eq "1.23M" } + it { 1_234_567.humanize(5).should eq "1.2346M" } + it { 12_345_678.humanize(5).should eq "12.346M" } + it { 0.012_345.humanize.should eq "12.3m" } + it { 0.001_234_5.humanize.should eq "1.23m" } + it { 0.000_000_012_345.humanize.should eq "12.3n" } + it { 0.000_000_001.humanize.should eq "1.0n" } + it { 0.000_000_001_235.humanize.should eq "1.24n" } + it { 0.123_456_78.humanize.should eq "123m" } + it { 0.123_456_78.humanize(5).should eq "123.46m" } + + it { 1_234.567_890_123.humanize(precision: 2, significant: false).should eq("1.23k") } + it { 123.456_789_012_3.humanize(precision: 2, significant: false).should eq("123.46") } + it { 12.345_678_901_23.humanize(precision: 2, significant: false).should eq("12.35") } + it { 1.234_567_890_123.humanize(precision: 2, significant: false).should eq("1.23") } + + it { 0.123_456_789_012.humanize(precision: 2, significant: false).should eq("123.46m") } + it { 0.012_345_678_901.humanize(precision: 2, significant: false).should eq("12.35m") } + it { 0.001_234_567_890.humanize(precision: 2, significant: false).should eq("1.23m") } + + it { 0.000_123_456_789.humanize(precision: 2, significant: false).should eq("123.46µ") } + it { 0.000_012_345_678.humanize(precision: 2, significant: false).should eq("12.35µ") } + it { 0.000_001_234_567.humanize(precision: 2, significant: false).should eq("1.23µ") } + + it { 0.000_000_123_456.humanize(precision: 2, significant: false).should eq("123.46n") } + it { 0.000_000_012_345.humanize(precision: 2, significant: false).should eq("12.35n") } + it { 0.000_000_001_234.humanize(precision: 2, significant: false).should eq("1.23n") } + it { 0.000_000_000_123.humanize(precision: 2, significant: false).should eq("123.00p") } + + describe "using custom prefixes" do + it { 1_420_000_000.humanize(prefixes: LENGTH_UNITS).should eq "1,420,000 km" } + it { 1_420.humanize(prefixes: LENGTH_UNITS).should eq "1.42 km" } + it { 1.humanize(prefixes: LENGTH_UNITS).should eq "1.0 m" } + it { 0.1.humanize(prefixes: LENGTH_UNITS).should eq "10.0 cm" } + it { 0.01.humanize(prefixes: LENGTH_UNITS).should eq "1.0 cm" } + it { 0.001.humanize(prefixes: LENGTH_UNITS).should eq "1.0 mm" } + it { 0.000_01.humanize(prefixes: LENGTH_UNITS).should eq "10.0 µm" } + it { 0.000_000_001.humanize(prefixes: LENGTH_UNITS).should eq "1.0 nm" } + end + end +end + +describe Int do + describe "#humanize_bytes" do + # default IEC + it { 1024.humanize_bytes.should eq "1.0kiB" } + + it { 0.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "0B" } + it { 1.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "1B" } + it { 1000.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "1000B" } + it { 1014.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "0.99KB" } + it { 1015.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "1.0KB" } + it { 1024.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "1.0KB" } + it { 1025.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "1.01KB" } + it { 2048.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq "2.0KB" } + + it { 1536.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq("1.5KB") } + it { 524288.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq("512KB") } + it { 1048576.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq("1.0MB") } + it { 1073741824.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq("1.0GB") } + it { 1099511627776.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq("1.0TB") } + it { 1125899906842624.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq("1.0PB") } + it { 1152921504606846976.humanize_bytes(format: Int::BinaryPrefixFormat::JEDEC).should eq("1.0EB") } + + it { 1024.humanize_bytes(format: Int::BinaryPrefixFormat::IEC).should eq "1.0kiB" } + it { 1073741824.humanize_bytes(format: Int::BinaryPrefixFormat::IEC).should eq "1.0GiB" } + end +end diff --git a/src/benchmark/ips.cr b/src/benchmark/ips.cr index 34014f16b62c..a9736315a3dc 100644 --- a/src/benchmark/ips.cr +++ b/src/benchmark/ips.cr @@ -43,15 +43,15 @@ module Benchmark def report max_label = ran_items.max_of &.label.size max_compare = ran_items.max_of &.human_compare.size - max_bytes_per_op = ran_items.max_of &.bytes_per_op.to_s.size + max_bytes_per_op = ran_items.max_of &.bytes_per_op.humanize(base: 1024).size ran_items.each do |item| - printf "%s %s (%s) (±%5.2f%%) %s B/op %s\n", + printf "%s %s (%s) (±%5.2f%%) %sB/op %s\n", item.label.rjust(max_label), item.human_mean, item.human_iteration_time, item.relative_stddev, - item.bytes_per_op.to_s.rjust(max_bytes_per_op), + item.bytes_per_op.humanize(base: 1024).rjust(max_bytes_per_op), item.human_compare.rjust(max_compare) end end @@ -185,43 +185,16 @@ module Benchmark end def human_mean - case Math.log10(mean) - when Float64::MIN..3 - digits = mean - suffix = ' ' - when 3..6 - digits = mean / 1000 - suffix = 'k' - when 6..9 - digits = mean / 1_000_000 - suffix = 'M' - else - digits = mean / 1_000_000_000 - suffix = 'G' - end - - "#{digits.round(2).to_s.rjust(6)}#{suffix}" + mean.humanize(precision: 2, significant: false, prefixes: Number::SI_PREFIXES_PADDED).rjust(7) end def human_iteration_time iteration_time = 1.0 / mean - case Math.log10(iteration_time) - when 0..Float64::MAX - digits = iteration_time - suffix = "s " - when -3..0 - digits = iteration_time * 1000 - suffix = "ms" - when -6..-3 - digits = iteration_time * 1_000_000 - suffix = "µs" - else - digits = iteration_time * 1_000_000_000 - suffix = "ns" - end - - "#{digits.round(2).to_s.rjust(6)}#{suffix}" + iteration_time.humanize(precision: 2, significant: false) do |magnitude, _| + magnitude = Number.prefix_index(magnitude).clamp(-9..0) + {magnitude, magnitude == 0 ? "s " : "#{Number.si_prefix(magnitude)}s"} + end.rjust(8) end def human_compare diff --git a/src/http/server/handlers/log_handler.cr b/src/http/server/handlers/log_handler.cr index 7e76be855f42..b894f47ef872 100644 --- a/src/http/server/handlers/log_handler.cr +++ b/src/http/server/handlers/log_handler.cr @@ -22,12 +22,6 @@ class HTTP::LogHandler minutes = elapsed.total_minutes return "#{minutes.round(2)}m" if minutes >= 1 - seconds = elapsed.total_seconds - return "#{seconds.round(2)}s" if seconds >= 1 - - millis = elapsed.total_milliseconds - return "#{millis.round(2)}ms" if millis >= 1 - - "#{(millis * 1000).round(2)}µs" + "#{elapsed.total_seconds.humanize(precision: 2, significant: false)}s" end end diff --git a/src/humanize.cr b/src/humanize.cr new file mode 100644 index 000000000000..34464eb562b9 --- /dev/null +++ b/src/humanize.cr @@ -0,0 +1,309 @@ +struct Number + # Prints this number as a `String` using a customizable format. + # + # *separator* is used as decimal separator, *delimiter* as thousands + # delimiter between batches of *group* digits. + # + # If *decimal_places* is `nil`, all significant decimal places are printed + # (similar to `#to_s`). If the argument has a numeric value, the number of + # visible decimal places will be fixed to that amount. + # + # Trailing zeros are omitted if *only_significant* is `true`. + # + # ``` + # 123_456.789.format # => "123,456.789" + # 123_456.789.format(',', '.') # => "123.456,789" + # 123_456.789.format(decimal_places: 2) # => "123,456.79" + # 123_456.789.format(decimal_places: 6) # => "123,456.789000" + # 123_456.789.format(decimal_places: 6, only_significant: true) # => "123,456.789" + # ``` + def format(io : IO, separator = '.', delimiter = ',', decimal_places : Int? = nil, *, group : Int = 3, only_significant : Bool = false) : Nil + number = self + # TODO: Optimize implementation for Int + if decimal_places + number = number.round(decimal_places) + end + string = number.abs.to_s + integer, _, decimals = string.partition('.') + + int_size = integer.size + dec_size = decimals.size + + io << '-' if self < 0 + + start = int_size % group + start += group if start == 0 + io.write integer.to_slice[0, start] + + while start < int_size + io << delimiter + io.write integer.to_slice[start, group] + start += group + end + + decimal_places ||= dec_size + + if decimal_places > 0 + io << separator << decimals + unless only_significant + (decimal_places - dec_size).times do + io << '0' + end + end + end + end + + # ditto + def format(separator = '.', delimiter = ',', decimal_places : Int? = nil, *, group : Int = 3, only_significant : Bool = false) : String + String.build do |io| + format(io, separator, delimiter, decimal_places, group: group, only_significant: only_significant) + end + end + + # Default SI prefixes ordered by magnitude. + SI_PREFIXES = { {'y', 'z', 'a', 'f', 'p', 'n', 'µ', 'm'}, {nil, 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'} } + + # SI prefixes used by `#humanize`. Equal to `SI_PREFIXES` but prepends the + # prefix with a space charater. + SI_PREFIXES_PADDED = ->(magnitude : Int32, _number : Float64) do + magnitude = Number.prefix_index(magnitude) + {magnitude, (magnitude == 0 ? " " : si_prefix(magnitude))} + end + + # Returns the SI prefix for *magnitude*. + # + # ``` + # Number.si_prefix(3) # => 'k' + # ``` + def self.si_prefix(magnitude : Int, prefixes = SI_PREFIXES) : Char? + index = (magnitude / 3) + prefixes = prefixes[magnitude < 0 ? 0 : 1] + prefixes[index.clamp((-prefixes.size + 1)..(prefixes.size - 1))] + end + + # :nodoc: + def self.prefix_index(i, group = 3) + ((i - (i > 0 ? 1 : 0)) / group) * group + end + + # Pretty prints this number as a `String` in a human-readable format. + # + # This is particularly useful if a number can have a wide value range and + # the *exact* value is less relevant. + # + # It rounds the number to the nearest thousands magnitude with *precision* + # number of significant digits. The order of magnitude is expressed with an + # appended quantifier. + # By default, SI prefixes are used (see `SI_PREFIXES`). + # + # ``` + # 1_200_000_000.humanize # => "1.2G" + # 0.000_000_012.humanize # => "12n" + # ``` + # + # If *significant* is `false`, the number of *precision* digits is preserved + # after the decimal separator. + # + # ``` + # 1_234.567_890.humanize(precision: 2) # => "1.2k" + # 1_234.567_890.humanize(precision: 2, significant: false) # => "1.23k" + # ``` + # + # *separator* describes the decimal separator, *delimiter* the thousands + # delimiter (see `#format`). + # + # See `Int#humanize_bytes` to format a file size. + def humanize(io : IO, precision = 3, separator = '.', delimiter = ',', *, base = 10 ** 3, significant = true, prefixes : Indexable = SI_PREFIXES) : Nil + humanize(io, precision, separator, delimiter, base: base, significant: significant) do |magnitude, _| + magnitude = Number.prefix_index(magnitude) + {magnitude, Number.si_prefix(magnitude, prefixes)} + end + end + + # ditto + def humanize(precision = 3, separator = '.', delimiter = ',', *, base = 10 ** 3, significant = true, prefixes = SI_PREFIXES) : String + String.build do |io| + humanize(io, precision, separator, delimiter, base: base, significant: significant, prefixes: prefixes) + end + end + + # Pretty prints this number as a `String` in a human-readable format. + # + # This is particularly useful if a number can have a wide value range and + # the *exact* value is less relevant. + # + # It rounds the number to the nearest thousands magnitude with *precision* + # number of significant digits. The order of magnitude is expressed with an + # appended quantifier. + # By default, SI prefixes are used (see `SI_PREFIXES`). + # + # ``` + # 1_200_000_000.humanize # => "1.2G" + # 0.000_000_012.humanize # => "12n" + # ``` + # + # If *significant* is `false`, the number of *precision* digits is preserved + # after the decimal separator. + # + # ``` + # 1_234.567_890.humanize(precision: 2) # => "1.2k" + # 1_234.567_890.humanize(precision: 2, significant: false) # => "1.23k" + # ``` + # + # *separator* describes the decimal separator, *delimiter* the thousands + # delimiter (see `#format`). + # + # This methods yields the order of magnitude and `self` and expects the block + # to return a `Tuple(Int32, _)` containing the (adjusted) magnitude and unit. + # The magnitude is typically adjusted to a multiple of `3`. + # + # ``` + # def humanize_length(number) + # number.humanize do |magnitude, number| + # case magnitude + # when -2, -1 then {-2, " cm"} + # when .>=(4) + # {3, " km"} + # else + # magnitude = Number.prefix_index(magnitude) + # {magnitude, " #{Number.si_prefix(magnitude)}m"} + # end + # end + # end + # + # humanize_length(1_420) # => "1.42 km" + # humanize_length(0.23) # => "23 cm" + # ``` + # + # See `Int#humanize_bytes` to format a file size. + def humanize(io : IO, precision = 3, separator = '.', delimiter = ',', *, base = 10 ** 3, significant = true, &prefixes : (Int32, Float64) -> {Int32, _} | {Int32, _, Bool}) : Nil + if zero? + digits = 0 + else + log = Math.log10(abs) + digits = log.ceil.to_i + digits += 1 if log < 0 && log == log.ceil + end + + magnitude = digits + + proper_fraction = 0 < abs < 1 + if proper_fraction + magnitude -= 1 + elsif magnitude == 0 + magnitude = 1 + end + + magnitude, unit = yield_result = yield magnitude, self.to_f + + decimal_places = precision + if significant + scrap_digits = digits - precision + decimal_places += magnitude - digits + else + scrap_digits = magnitude - precision + end + scrap_digits *= -1 if proper_fraction + + exponent = 10 ** scrap_digits.to_f + if proper_fraction + number = (to_f * exponent).round / exponent + else + number = (to_f / exponent).round * exponent + end + + number /= base.to_f ** (magnitude.to_f / 3.0) + + # Scrap decimal places if magnitude lower bound == 0 + # to return e.g. "1B" instead of "1.0B" for humanize_bytes. + decimal_places = 0 if yield_result[2]? == false + + number.format(io, separator, delimiter, decimal_places: decimal_places, only_significant: significant) + + io << unit + end + + # ditto + def humanize(precision = 3, separator = '.', delimiter = ',', *, base = 10 ** 3, significant = true) : String + String.build do |io| + humanize(io, precision, separator, delimiter, base: base, significant: significant) do |magnitude, number| + yield magnitude, number + end + end + end + + # ditto + def humanize(io : IO, precision = 3, separator = '.', delimiter = ',', *, base = 10 ** 3, significant = true, prefixes : Proc) : Nil + humanize(io, precision, separator, delimiter, base: base, significant: significant) do |magnitude, number| + prefixes.call(magnitude, number) + end + end + + # ditto + def humanize(precision = 3, separator = '.', delimiter = ',', *, base = 10 ** 3, significant = true, prefixes : Proc) : Nil + String.build do |io| + humanize(io, precision, separator, delimiter, base: base, significant: significant, prefixes: prefixes) + end + end +end + +struct Int + enum BinaryPrefixFormat + # The IEC standard prefixes (`Ki`, `Mi`, `Gi`, `Ti`, `Pi`, `Ei`, `Zi`, `Yi`) + # based on powers of 1000. + IEC + + # Extended range of the JEDEC units (`K`, `M`, `G`, `T`, `P`, `E`, `Z`, `Y`) which equals to + # the prefixes of the SI system except for uppercase `K` and is based on + # powers of 1024. + JEDEC + end + + # Prints this integer as a binary value in a human-readable format using + # a `BinaryPrefixFormat`. + # + # Values with binary measurements such as computer storage (e.g. RAM size) are + # typically expressed using unit prefixes based on 1024 (instead of multiples + # of 1000 as per SI standard). This method by default uses the IEC standard + # prefixes (`Ki`, `Mi`, `Gi`, `Ti`, `Pi`, `Ei`, `Zi`, `Yi`) based on powers of + # 1000 (see `BinaryPrefixFormat::IEC`). + # + # *format* can be set to use the extended range of JEDEC units (`K`, `M`, `G`, + # `T`, `P`, `E`, `Z`, `Y`) which equals to the prefixes of the SI system + # except for uppercase `K` and is based on powers of 1024 (see + # `BinaryPrefixFormat::JEDEC`). + # + # ``` + # 1.humanize_bytes # => "1B" + # 1024.humanize_bytes # => "1.0KB" + # 1536.humanize_bytes # => "1.5KB" + # 524288.humanize_bytes # => "512KB" + # 1073741824.humanize_bytes(format: :IEC) # => "1.0GiB" + # ``` + # + # See `Number#humanize` for more details on the behaviour and arguments. + def humanize_bytes(io : IO, precision : Int = 3, separator = '.', *, significant : Bool = true, format : BinaryPrefixFormat = :IEC) : Nil + humanize(io, precision, separator, nil, base: 1024, significant: significant) do |magnitude| + magnitude = Number.prefix_index(magnitude) + + prefix = Number.si_prefix(magnitude) + if prefix.nil? + unit = "B" + else + if format.iec? + unit = "#{prefix}iB" + else + unit = "#{prefix.upcase}B" + end + end + {magnitude, unit, magnitude > 0} + end + end + + # ditto + def humanize_bytes(precision : Int = 3, separator = '.', *, significant : Bool = true, format : BinaryPrefixFormat = :IEC) : String + String.build do |io| + humanize_bytes(io, precision, separator, significant: significant, format: format) + end + end +end diff --git a/src/prelude.cr b/src/prelude.cr index 453cf3bbb0f8..0b24a1ad238e 100644 --- a/src/prelude.cr +++ b/src/prelude.cr @@ -58,6 +58,7 @@ no_win require "mutex" require "named_tuple" require "nil" require "number" +require "humanize" require "pointer" require "pretty_print" require "primitives"