ch_connect 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5856233f30844a931e61ac5aca11af740c7a876a95ae19e497a3157bca9097b2
4
- data.tar.gz: 6588fea1311f26c52e6b13885316741e47a98a284751456d9a5c3e022d036c04
3
+ metadata.gz: 88e326f103cde2be1cd0ed39c0615ff2861020fb2f5d334fcb08ff012196cd3b
4
+ data.tar.gz: 0c6b12e11fdfec5626fefa1cbbcf81da8ed384f4983d1caf0466758adcb617db
5
5
  SHA512:
6
- metadata.gz: 8a2650b889220c5b2e66803f0da23022357fd6c6997e7d09841b983768d099bb487b4e6ad51c8eadb18e255e966afa318d7652a176dd01a44819a43989bb1aef
7
- data.tar.gz: 0310c5b3de97b1388d85eab045c1c21ff8ab5ab7354cc092683e1c383532c93c77ca6cdffd3bc86b0bdcd6d8ae943b26c3eb00616e996f732dd9f658ce892485
6
+ metadata.gz: fda2b6e230f5a66d66a0cd4189dc324f76713756c458bd67e06f1dc0873ec589c53980e497eaa9d37a5dd985ba0f8ef60264bafe8750b2d331ce33e93d12b574
7
+ data.tar.gz: eaa8b14dd4da0af03c2c3ab66825ab27c4770d0f7a7dd596deb4daa06a6b0fc3191a9454b4bd0f3411b3cbe1f85dd5eaf1ff2546a2e20c9160d7f94ae1572b43
data/CHANGELOG.md CHANGED
@@ -1,4 +1,8 @@
1
- ## Unreleased
1
+ ## [0.4.1] - 2026-10-12
2
+
3
+ - Improved large result decoding speed (about 1.9x faster for the 100K-row benchmark query) and reduced its allocations from 97 MB to 55 MB
4
+ - Fixed quadratic re-parsing of large uncompressed result blocks (about 5x faster for multi-megabyte String columns)
5
+ - Faster decoding of repeated `Date`/`Date32` values, `Nullable(String)`, `UUID`, and `Map` with String keys, and faster `Response#each`
2
6
 
3
7
  ## [0.4.0] - 2026-10-06
4
8
 
data/README.md CHANGED
@@ -22,27 +22,27 @@ Speed (iterations/second, higher is better):
22
22
 
23
23
  | Scenario | ch_connect | click_house | clickhouse | click_house-client |
24
24
  |----------|------------|-------------|------------|--------------------|
25
- | Small queries (10 rows) | **962 i/s** | 363 i/s (2.7x slower) | 317 i/s (3.0x slower) | 372 i/s (2.6x slower) |
26
- | Large queries (100K rows) | **8.2 i/s** | 1.1 i/s (7.2x slower) | 0.6 i/s (14.1x slower) | 1.5 i/s (5.3x slower) |
25
+ | Small queries (10 rows) | **1961 i/s** | 718 i/s (2.7x slower) | 524 i/s (3.7x slower) | 796 i/s (2.5x slower) |
26
+ | Large queries (100K rows) | **14.2 i/s** | 1.4 i/s (10.5x slower) | 0.5 i/s (26.4x slower) | 1.8 i/s (8.0x slower) |
27
27
 
28
28
  Multi-threaded throughput (10K-row query, total queries/second):
29
29
 
30
30
  | Threads | ch_connect | click_house | clickhouse | click_house-client |
31
31
  |---------|------------|-------------|------------|--------------------|
32
- | 1 | **292 q/s** | 69 q/s | 117 q/s | 48 q/s |
33
- | 4 | **598 q/s** | 162 q/s | 319 q/s | 82 q/s |
34
- | 8 | **606 q/s** | 158 q/s | 321 q/s | 80 q/s |
32
+ | 1 | **523 q/s** | 74 q/s | 140 q/s | 56 q/s |
33
+ | 4 | **755 q/s** | 148 q/s | 306 q/s | 78 q/s |
34
+ | 8 | **637 q/s** | 150 q/s | 326 q/s | 82 q/s |
35
35
 
36
36
  Memory allocated for a large query (lower is better):
37
37
 
38
38
  | Gem | Allocated |
39
39
  |-----|-----------|
40
- | ch_connect | **97 MB** |
40
+ | ch_connect | **55 MB** |
41
41
  | click_house | 198 MB |
42
42
  | click_house-client | 210 MB |
43
43
  | clickhouse | 436 MB |
44
44
 
45
- A small query allocates approximately **3.2 KB / 32 Ruby objects**. See the [`benchmark/`](benchmark/) directory for the comparison suite and methodology.
45
+ A small query allocates approximately **3.6 KB / 40 Ruby objects**. See the [`benchmark/`](benchmark/) directory for the comparison suite and methodology.
46
46
 
47
47
  ## Installation
48
48
 
@@ -58,6 +58,9 @@ static VALUE sym_zstd;
58
58
  /* Julian day number of 1970-01-01 (Date.jd(2440588) == Date.new(1970, 1, 1)) */
59
59
  #define UNIX_EPOCH_JD 2440588
60
60
 
61
+ /* Per-column Date cache slots; must be a power of two */
62
+ #define DATE_CACHE_SIZE 256
63
+
61
64
  /* One immutable codec shared by all connections and threads: the built-in
62
65
  * adapters are stateless wrappers over the one-shot lz4/zstd functions. Both
63
66
  * slot pairs are filled (when available) so decode survives a server-side
@@ -291,17 +294,23 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
291
294
  }
292
295
  break;
293
296
  }
294
- case CHC_DATE: {
295
- for (long i = 0; i < n_rows; i++) {
296
- uint16_t days = load_u16le(data + i * 2);
297
- rb_ary_push(ary, rb_funcall(cDate, id_jd, 1, LONG2NUM(UNIX_EPOCH_JD + (long)days)));
298
- }
299
- break;
300
- }
297
+ case CHC_DATE:
301
298
  case CHC_DATE32: {
299
+ /* Date construction is a Ruby method call and columns usually repeat
300
+ * a handful of days, so reuse (immutable) Dates through a small
301
+ * direct-mapped cache. Every cached Date is already anchored by ary. */
302
+ long cache_days[DATE_CACHE_SIZE];
303
+ VALUE cache_vals[DATE_CACHE_SIZE];
304
+ for (int k = 0; k < DATE_CACHE_SIZE; k++) cache_vals[k] = Qundef;
302
305
  for (long i = 0; i < n_rows; i++) {
303
- int32_t days = load_i32le(data + i * 4);
304
- rb_ary_push(ary, rb_funcall(cDate, id_jd, 1, LONG2NUM(UNIX_EPOCH_JD + (long)days)));
306
+ long days = kind == CHC_DATE ? (long)load_u16le(data + i * 2)
307
+ : (long)load_i32le(data + i * 4);
308
+ unsigned long slot = (unsigned long)days & (DATE_CACHE_SIZE - 1);
309
+ if (cache_vals[slot] == Qundef || cache_days[slot] != days) {
310
+ cache_days[slot] = days;
311
+ cache_vals[slot] = rb_funcall(cDate, id_jd, 1, LONG2NUM(UNIX_EPOCH_JD + days));
312
+ }
313
+ rb_ary_push(ary, cache_vals[slot]);
305
314
  }
306
315
  break;
307
316
  }
@@ -331,18 +340,23 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
331
340
  break;
332
341
  }
333
342
  case CHC_UUID: {
334
- char buf[37];
343
+ /* Each 8-byte half is little-endian on the wire; emit both halves
344
+ * most significant nibble first, with dashes after hex digits
345
+ * 8, 12, 16 and 20. */
346
+ static const char hex[] = "0123456789abcdef";
335
347
  for (long i = 0; i < n_rows; i++) {
336
348
  const uint8_t *p = data + i * 16;
337
- uint64_t hi = load_u64le(p);
338
- uint64_t lo = load_u64le(p + 8);
339
- snprintf(buf, sizeof(buf), "%08llx-%04llx-%04llx-%04llx-%012llx",
340
- (unsigned long long)(hi >> 32),
341
- (unsigned long long)((hi >> 16) & 0xFFFF),
342
- (unsigned long long)(hi & 0xFFFF),
343
- (unsigned long long)(lo >> 48),
344
- (unsigned long long)(lo & 0xFFFFFFFFFFFFULL));
345
- rb_ary_push(ary, rb_utf8_str_new(buf, 36));
349
+ uint64_t halves[2] = { load_u64le(p), load_u64le(p + 8) };
350
+ VALUE str = rb_utf8_str_new(NULL, 36);
351
+ char *out = RSTRING_PTR(str);
352
+ int o = 0;
353
+ for (int h = 0; h < 2; h++) {
354
+ for (int shift = 60; shift >= 0; shift -= 4) {
355
+ if (o == 8 || o == 13 || o == 18 || o == 23) out[o++] = '-';
356
+ out[o++] = hex[(halves[h] >> shift) & 0xF];
357
+ }
358
+ }
359
+ rb_ary_push(ary, str);
346
360
  }
347
361
  break;
348
362
  }
@@ -379,17 +393,37 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
379
393
  return ary;
380
394
  }
381
395
 
396
+ /* null_map (optional) marks rows that a Nullable wrapper turns into nil, so
397
+ * their placeholder strings are never allocated. */
398
+ static VALUE
399
+ decode_string_column(const chc_column *col, long n_rows, const uint8_t *null_map)
400
+ {
401
+ const uint8_t *data = chc_column_string_data(col);
402
+ const uint64_t *offsets = chc_column_string_offsets(col);
403
+ VALUE ary = rb_ary_new_capa(n_rows);
404
+
405
+ uint64_t start = 0;
406
+ for (long i = 0; i < n_rows; i++) {
407
+ uint64_t end = offsets[i];
408
+ if (null_map && null_map[i] == 1) rb_ary_push(ary, Qnil);
409
+ else rb_ary_push(ary, rb_utf8_str_new((const char *)data + start, (long)(end - start)));
410
+ start = end;
411
+ }
412
+ return ary;
413
+ }
414
+
382
415
  static VALUE
383
- decode_string_column(const chc_column *col, long n_rows)
416
+ decode_interned_string_column(const chc_column *col, long n_rows)
384
417
  {
385
418
  const uint8_t *data = chc_column_string_data(col);
386
419
  const uint64_t *offsets = chc_column_string_offsets(col);
420
+ rb_encoding *utf8 = rb_utf8_encoding();
387
421
  VALUE ary = rb_ary_new_capa(n_rows);
388
422
 
389
423
  uint64_t start = 0;
390
424
  for (long i = 0; i < n_rows; i++) {
391
425
  uint64_t end = offsets[i];
392
- rb_ary_push(ary, rb_utf8_str_new((const char *)data + start, (long)(end - start)));
426
+ rb_ary_push(ary, rb_enc_interned_str((const char *)data + start, (long)(end - start), utf8));
393
427
  start = end;
394
428
  }
395
429
  return ary;
@@ -492,12 +526,14 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
492
526
  return decode_fixed(col, t, n_rows, state);
493
527
 
494
528
  case CHC_COL_STRING:
495
- return decode_string_column(col, n_rows);
529
+ return decode_string_column(col, n_rows, NULL);
496
530
 
497
531
  case CHC_COL_NULLABLE: {
498
532
  const uint8_t *null_map = chc_column_null_map(col);
499
533
  const chc_column *inner = chc_column_nullable_inner(col);
500
534
  const chc_type *inner_t = chc_type_child(t, 0);
535
+ if (chc_column_layout(inner) == CHC_COL_STRING)
536
+ return decode_string_column(inner, n_rows, null_map);
501
537
  VALUE vals = decode_column(inner, inner_t, n_rows, state);
502
538
  for (long i = 0; i < n_rows; i++) {
503
539
  if (null_map[i] == 1) rb_ary_store(vals, i, Qnil);
@@ -516,7 +552,12 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
516
552
  long total = (long)chc_column_n_rows(values_col);
517
553
  const chc_column *keys_col = chc_column_tuple_child(values_col, 0);
518
554
  const chc_column *vals_col = chc_column_tuple_child(values_col, 1);
519
- VALUE keys = decode_column(keys_col, kt, total, state);
555
+ /* Hash#[]= dups and freezes unfrozen String keys; decoding them
556
+ * as interned (frozen, deduplicated) strings skips that copy, and
557
+ * map keys typically repeat across rows. */
558
+ VALUE keys = chc_column_layout(keys_col) == CHC_COL_STRING
559
+ ? decode_interned_string_column(keys_col, total)
560
+ : decode_column(keys_col, kt, total, state);
520
561
  VALUE vals = decode_column(vals_col, vt, total, state);
521
562
 
522
563
  VALUE ary = rb_ary_new_capa(n_rows);
@@ -529,7 +570,7 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
529
570
  "Map offset out of bounds at row %ld: %llu", i,
530
571
  (unsigned long long)end);
531
572
  }
532
- VALUE hash = rb_hash_new();
573
+ VALUE hash = rb_hash_new_capa((long)(end - start));
533
574
  for (uint64_t j = start; j < end; j++) {
534
575
  rb_hash_aset(hash, RARRAY_AREF(keys, (long)j), RARRAY_AREF(vals, (long)j));
535
576
  }
@@ -913,13 +954,16 @@ native_client_decode_block(VALUE self, native_client_t *nc, chc_block *block)
913
954
  (long)n_rows, &nc->state);
914
955
  rb_ary_push(col_vals, cols[i]);
915
956
  }
957
+ /* row_vals only holds references that cols[] already anchors */
958
+ VALUE row_buf;
959
+ VALUE *row_vals = ALLOCV_N(VALUE, row_buf, n_cols);
916
960
  for (size_t r = 0; r < n_rows; r++) {
917
- VALUE row = rb_ary_new_capa((long)n_cols);
918
961
  for (size_t c = 0; c < n_cols; c++) {
919
- rb_ary_push(row, RARRAY_AREF(cols[c], (long)r));
962
+ row_vals[c] = RARRAY_AREF(cols[c], (long)r);
920
963
  }
921
- rb_ary_push(rows, row);
964
+ rb_ary_push(rows, rb_ary_new_from_values((long)n_cols, row_vals));
922
965
  }
966
+ ALLOCV_END(row_buf);
923
967
  ALLOCV_END(cols_buf);
924
968
  RB_GC_GUARD(col_vals);
925
969
  }
@@ -27,6 +27,7 @@ module ChConnect
27
27
 
28
28
  READ_CHUNK = 64 * 1024
29
29
  WRITE_CHUNK = 64 * 1024
30
+ DRAIN_LIMIT = 16 * 1024 * 1024
30
31
  PARAM_ESCAPES = {
31
32
  "\0" => "\\0", "\a" => "\\a", "\b" => "\\b", "\e" => "\\e",
32
33
  "\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t",
@@ -60,7 +61,7 @@ module ChConnect
60
61
  loop do
61
62
  case @client.recv_step
62
63
  when :done then break
63
- when :want_read then @client.feed(read_chunk)
64
+ when :want_read then feed_available
64
65
  end
65
66
  end
66
67
  @client.take_result
@@ -191,6 +192,23 @@ module ChConnect
191
192
  end
192
193
  end
193
194
 
195
+ # Blocks for one chunk, then drains whatever else the socket already has
196
+ # buffered (up to DRAIN_LIMIT). An uncompressed Data block that ends
197
+ # mid-column is re-parsed from that column's start on the next
198
+ # recv_step, so retrying once per burst instead of once per chunk keeps
199
+ # large blocks from going quadratic.
200
+ def feed_available
201
+ @client.feed(read_chunk)
202
+ drained = 0
203
+ while drained < DRAIN_LIMIT
204
+ chunk = @socket.read_nonblock(READ_CHUNK, @read_buf, exception: false)
205
+ break unless chunk.is_a?(String)
206
+
207
+ @client.feed(chunk)
208
+ drained += chunk.bytesize
209
+ end
210
+ end
211
+
194
212
  # Reads into a reusable buffer: feed copies the bytes synchronously,
195
213
  # so the buffer never needs to survive past the next read.
196
214
  def read_chunk(timeout = @config.read_timeout)
@@ -17,7 +17,18 @@ module ChConnect
17
17
  def each
18
18
  return to_enum(:each) unless block_given?
19
19
 
20
- rows.each { |row| yield columns.zip(row).to_h }
20
+ # An index loop avoids zip's per-row pair arrays (~3x faster under YJIT).
21
+ names = columns
22
+ count = names.size
23
+ rows.each do |row|
24
+ hash = {}
25
+ index = 0
26
+ while index < count
27
+ hash[names[index]] = row[index]
28
+ index += 1
29
+ end
30
+ yield hash
31
+ end
21
32
  end
22
33
  end
23
34
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChConnect
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ch_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karol Bąk