Skip to content

Commit 57e3fc3

Browse files
committed
Move Time#xmlschema in core and optimize it
[Feature #20707] Converting Time into RFC3339 / ISO8601 representation is an significant hotspot for applications that serialize data in JSON, XML or other formats. By moving it into core we can optimize it much further than what `strftime` will allow. ``` compare-ruby: ruby 3.4.0dev (2024-08-29T13:11:40Z master 6b08a50) +YJIT [arm64-darwin23] built-ruby: ruby 3.4.0dev (2024-08-30T13:17:32Z native-xmlschema 34041ff) +YJIT [arm64-darwin23] warming up...... | |compare-ruby|built-ruby| |:-----------------------|-----------:|---------:| |time.xmlschema | 1.087M| 5.190M| | | -| 4.78x| |utc_time.xmlschema | 1.464M| 6.848M| | | -| 4.68x| |time.xmlschema(6) | 859.960k| 4.646M| | | -| 5.40x| |utc_time.xmlschema(6) | 1.080M| 5.917M| | | -| 5.48x| |time.xmlschema(9) | 893.909k| 4.668M| | | -| 5.22x| |utc_time.xmlschema(9) | 1.056M| 5.707M| | | -| 5.40x| ```
1 parent d4de8ae commit 57e3fc3

File tree

9 files changed

+207
-3
lines changed

9 files changed

+207
-3
lines changed

benchmark/time_xmlschema.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
prelude: |
2+
# frozen_string_literal
3+
unless Time.method_defined?(:xmlschema)
4+
class Time
5+
def xmlschema(fraction_digits=0)
6+
fraction_digits = fraction_digits.to_i
7+
s = strftime("%FT%T")
8+
if fraction_digits > 0
9+
s << strftime(".%#{fraction_digits}N")
10+
end
11+
s << (utc? ? 'Z' : strftime("%:z"))
12+
end
13+
end
14+
end
15+
time = Time.now
16+
utc_time = Time.now.utc
17+
benchmark:
18+
- time.xmlschema
19+
- utc_time.xmlschema
20+
- time.xmlschema(6)
21+
- utc_time.xmlschema(6)
22+
- time.xmlschema(9)
23+
- utc_time.xmlschema(9)

spec/ruby/core/time/iso8601_spec.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
require_relative '../../spec_helper'
2+
require_relative 'shared/xmlschema'
3+
4+
describe "Time#iso8601" do
5+
it_behaves_like :time_xmlschema, :iso8601
6+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
describe :time_xmlschema, shared: true do
2+
ruby_version_is "3.4" do
3+
it "generates ISO-8601 strings in Z for UTC times" do
4+
t = Time.utc(1985, 4, 12, 23, 20, 50, 521245)
5+
t.send(@method).should == "1985-04-12T23:20:50Z"
6+
t.send(@method, 2).should == "1985-04-12T23:20:50.52Z"
7+
t.send(@method, 9).should == "1985-04-12T23:20:50.521245000Z"
8+
end
9+
10+
it "generates ISO-8601 string with timeone offset for non-UTC times" do
11+
t = Time.new(1985, 4, 12, 23, 20, 50, "+02:00")
12+
t.send(@method).should == "1985-04-12T23:20:50+02:00"
13+
t.send(@method, 2).should == "1985-04-12T23:20:50.00+02:00"
14+
end
15+
16+
it "year is always at least 4 digits" do
17+
t = Time.utc(12, 4, 12)
18+
t.send(@method).should == "0012-04-12T00:00:00Z"
19+
end
20+
21+
it "year can be more than 4 digits" do
22+
t = Time.utc(40_000, 4, 12)
23+
t.send(@method).should == "40000-04-12T00:00:00Z"
24+
end
25+
26+
it "year can be negative" do
27+
t = Time.utc(-2000, 4, 12)
28+
t.send(@method).should == "-2000-04-12T00:00:00Z"
29+
end
30+
end
31+
end

spec/ruby/core/time/xmlschema_spec.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
require_relative '../../spec_helper'
2+
require_relative 'shared/xmlschema'
3+
4+
describe "Time#xmlschema" do
5+
it_behaves_like :time_xmlschema, :xmlschema
6+
end

spec/ruby/library/time/iso8601_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
require 'time'
44

55
describe "Time.xmlschema" do
6-
it_behaves_like :time_xmlschema, :iso8601
6+
it_behaves_like :time_library_xmlschema, :iso8601
77
end

spec/ruby/library/time/shared/xmlschema.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
describe :time_xmlschema, shared: true do
1+
describe :time_library_xmlschema, shared: true do
22
it "parses ISO-8601 strings" do
33
t = Time.utc(1985, 4, 12, 23, 20, 50, 520000)
44
s = "1985-04-12T23:20:50.52Z"

spec/ruby/library/time/xmlschema_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
require 'time'
44

55
describe "Time.xmlschema" do
6-
it_behaves_like :time_xmlschema, :xmlschema
6+
it_behaves_like :time_library_xmlschema, :xmlschema
77
end

test/ruby/test_time.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,4 +1444,60 @@ def test_deconstruct_keys
14441444
def test_parse_zero_bigint
14451445
assert_equal 0, Time.new("2020-10-28T16:48:07.000Z").nsec, '[Bug #19390]'
14461446
end
1447+
1448+
def test_xmlschema_encode
1449+
[:xmlschema, :iso8601].each do |method|
1450+
bug6100 = '[ruby-core:42997]'
1451+
1452+
t = Time.utc(2001, 4, 17, 19, 23, 17, 300000)
1453+
assert_equal("2001-04-17T19:23:17Z", t.__send__(method))
1454+
assert_equal("2001-04-17T19:23:17.3Z", t.__send__(method, 1))
1455+
assert_equal("2001-04-17T19:23:17.300000Z", t.__send__(method, 6))
1456+
assert_equal("2001-04-17T19:23:17.3000000Z", t.__send__(method, 7))
1457+
assert_equal("2001-04-17T19:23:17.3Z", t.__send__(method, 1.9), bug6100)
1458+
1459+
t = Time.utc(2001, 4, 17, 19, 23, 17, 123456)
1460+
assert_equal("2001-04-17T19:23:17.1234560Z", t.__send__(method, 7))
1461+
assert_equal("2001-04-17T19:23:17.123456Z", t.__send__(method, 6))
1462+
assert_equal("2001-04-17T19:23:17.12345Z", t.__send__(method, 5))
1463+
assert_equal("2001-04-17T19:23:17.1Z", t.__send__(method, 1))
1464+
assert_equal("2001-04-17T19:23:17.1Z", t.__send__(method, 1.9), bug6100)
1465+
1466+
t = Time.at(2.quo(3)).getlocal("+09:00")
1467+
assert_equal("1970-01-01T09:00:00.666+09:00", t.__send__(method, 3))
1468+
assert_equal("1970-01-01T09:00:00.6666666666+09:00", t.__send__(method, 10))
1469+
assert_equal("1970-01-01T09:00:00.66666666666666666666+09:00", t.__send__(method, 20))
1470+
assert_equal("1970-01-01T09:00:00.6+09:00", t.__send__(method, 1.1), bug6100)
1471+
assert_equal("1970-01-01T09:00:00.666+09:00", t.__send__(method, 3.2), bug6100)
1472+
1473+
t = Time.at(123456789.quo(9999999999)).getlocal("+09:00")
1474+
assert_equal("1970-01-01T09:00:00.012+09:00", t.__send__(method, 3))
1475+
assert_equal("1970-01-01T09:00:00.012345678+09:00", t.__send__(method, 9))
1476+
assert_equal("1970-01-01T09:00:00.0123456789+09:00", t.__send__(method, 10))
1477+
assert_equal("1970-01-01T09:00:00.0123456789012345678+09:00", t.__send__(method, 19))
1478+
assert_equal("1970-01-01T09:00:00.01234567890123456789+09:00", t.__send__(method, 20))
1479+
assert_equal("1970-01-01T09:00:00.012+09:00", t.__send__(method, 3.8), bug6100)
1480+
1481+
t = Time.utc(1)
1482+
assert_equal("0001-01-01T00:00:00Z", t.__send__(method))
1483+
1484+
begin
1485+
Time.at(-1)
1486+
rescue ArgumentError
1487+
# ignore
1488+
else
1489+
t = Time.utc(1960, 12, 31, 23, 0, 0, 123456)
1490+
assert_equal("1960-12-31T23:00:00.123456Z", t.__send__(method, 6))
1491+
end
1492+
1493+
assert_equal("10000-01-01T00:00:00Z", Time.utc(10000).__send__(method))
1494+
assert_equal("9999-01-01T00:00:00Z", Time.utc(9999).__send__(method))
1495+
assert_equal("0001-01-01T00:00:00Z", Time.utc(1).__send__(method)) # 1 AD
1496+
assert_equal("0000-01-01T00:00:00Z", Time.utc(0).__send__(method)) # 1 BC
1497+
assert_equal("-0001-01-01T00:00:00Z", Time.utc(-1).__send__(method)) # 2 BC
1498+
assert_equal("-0004-01-01T00:00:00Z", Time.utc(-4).__send__(method)) # 5 BC
1499+
assert_equal("-9999-01-01T00:00:00Z", Time.utc(-9999).__send__(method))
1500+
assert_equal("-10000-01-01T00:00:00Z", Time.utc(-10000).__send__(method))
1501+
end
1502+
end
14471503
end

time.c

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5215,6 +5215,86 @@ time_strftime(VALUE time, VALUE format)
52155215
}
52165216
}
52175217

5218+
static VALUE
5219+
time_xmlschema(int argc, VALUE *argv, VALUE time)
5220+
{
5221+
long fraction_digits = 0;
5222+
rb_check_arity(argc, 0, 1);
5223+
if (argc > 0) {
5224+
fraction_digits = NUM2LONG(argv[0]);
5225+
if (fraction_digits < 0) {
5226+
fraction_digits = 0;
5227+
}
5228+
}
5229+
5230+
struct time_object *tobj;
5231+
5232+
GetTimeval(time, tobj);
5233+
MAKE_TM(time, tobj);
5234+
5235+
long year = -1;
5236+
if (FIXNUM_P(tobj->vtm.year)) {
5237+
year = FIX2LONG(tobj->vtm.year);
5238+
}
5239+
if (RB_UNLIKELY(year > 9999 || year < 0 || fraction_digits > 9)) {
5240+
// Slow path for uncommon dates.
5241+
VALUE format = rb_utf8_str_new_cstr("%FT%T");
5242+
if (fraction_digits > 0) {
5243+
rb_str_catf(format, ".%%#%ldN", fraction_digits);
5244+
}
5245+
rb_str_cat_cstr(format, TZMODE_UTC_P(tobj) ? "Z" : "%:z");
5246+
return rb_funcallv(time, rb_intern("strftime"), 1, &format);
5247+
}
5248+
5249+
long buf_size = sizeof("YYYY-MM-DDTHH:MM:SS+ZH:ZM") + fraction_digits + (fraction_digits > 0 ? 1 : 0);
5250+
5251+
VALUE str = rb_str_buf_new(buf_size);
5252+
rb_enc_associate_index(str, rb_utf8_encindex());
5253+
5254+
char *ptr = RSTRING_PTR(str);
5255+
char *start = ptr;
5256+
int written = snprintf(
5257+
ptr,
5258+
sizeof("YYYY-MM-DDTHH:MM:SS"),
5259+
"%04ld-%02d-%02dT%02d:%02d:%02d",
5260+
year,
5261+
tobj->vtm.mon,
5262+
tobj->vtm.mday,
5263+
tobj->vtm.hour,
5264+
tobj->vtm.min,
5265+
tobj->vtm.sec
5266+
);
5267+
RUBY_ASSERT(written == sizeof("YYYY-MM-DDTHH:MM:SS") - 1);
5268+
ptr += written;
5269+
5270+
if (fraction_digits > 0) {
5271+
long nsec = NUM2LONG(mulquov(tobj->vtm.subsecx, INT2FIX(1000000000), INT2FIX(TIME_SCALE)));
5272+
long subsec = nsec / (long)pow(10, 9 - fraction_digits);
5273+
5274+
*ptr = '.';
5275+
ptr++;
5276+
5277+
written = snprintf(ptr, fraction_digits + 1, "%0*ld", (int)fraction_digits, subsec); // Always allow to write \0
5278+
RUBY_ASSERT(written > 0);
5279+
ptr += written;
5280+
}
5281+
5282+
if (TZMODE_UTC_P(tobj)) {
5283+
*ptr = 'Z';
5284+
ptr++;
5285+
}
5286+
else {
5287+
long offset = NUM2LONG(rb_time_utc_offset(time));
5288+
int offset_hours = (int)(offset / 3600);
5289+
int offset_minutes = (int)((offset % 3600 / 60));
5290+
written = snprintf(ptr, sizeof("+ZH:ZM"), "%+03d:%02d", offset_hours, offset_minutes);
5291+
RUBY_ASSERT(written == sizeof("+ZH:ZM") - 1);
5292+
ptr += written;
5293+
}
5294+
rb_str_set_len(str, ptr -start); // We could skip coderange scanning as we know it's full ASCII.
5295+
return str;
5296+
}
5297+
52185298
int ruby_marshal_write_long(long x, char *buf);
52195299

52205300
enum {base_dump_size = 8};
@@ -5842,6 +5922,8 @@ Init_Time(void)
58425922
rb_define_method(rb_cTime, "subsec", time_subsec, 0);
58435923

58445924
rb_define_method(rb_cTime, "strftime", time_strftime, 1);
5925+
rb_define_method(rb_cTime, "xmlschema", time_xmlschema, -1);
5926+
rb_define_alias(rb_cTime, "iso8601", "xmlschema");
58455927

58465928
/* methods for marshaling */
58475929
rb_define_private_method(rb_cTime, "_dump", time_dump, -1);

0 commit comments

Comments
 (0)