Skip to content

Commit

Permalink
Implement GVL release threshold
Browse files Browse the repository at this point in the history
  • Loading branch information
noteflakes committed Dec 24, 2023
1 parent b360200 commit fef32b6
Show file tree
Hide file tree
Showing 10 changed files with 206 additions and 26 deletions.
41 changes: 32 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ latest features and enhancements.
## Features

- Super fast - [up to 11x faster](#performance) than the
[sqlite3](https://github.com/sparklemotion/sqlite3-ruby) gem (see also
[comparison](#why-not-just-use-the-sqlite3-gem).)
[sqlite3](https://github.com/sparklemotion/sqlite3-ruby) gem.
- A variety of methods for different data access patterns: rows as hashes, rows
as arrays, single row, single column, single value.
- Prepared statements.
Expand All @@ -30,10 +29,12 @@ latest features and enhancements.
- Use system-installed sqlite3, or the [bundled latest version of
SQLite3](#installing-the-extralite-sqlite3-bundle).
- Improved [concurrency](#concurrency) for multithreaded apps: the Ruby GVL is
released while preparing SQL statements and while iterating over results.
released peridically while preparing SQL statements and while iterating over
results.
- Automatically execute SQL strings containing multiple semicolon-separated
queries (handy for creating/modifying schemas).
- Execute the same query with multiple parameter lists (useful for inserting records).
- Execute the same query with multiple parameter lists (useful for inserting
records).
- Load extensions (loading of extensions is autmatically enabled. You can find
some useful extensions here: https://github.com/nalgeon/sqlean.)
- Includes a [Sequel adapter](#usage-with-sequel).
Expand Down Expand Up @@ -331,11 +332,33 @@ p articles.to_a

## Concurrency

Extralite releases the GVL while making blocking calls to the sqlite3 library,
that is while preparing SQL statements and fetching rows. Releasing the GVL
allows other threads to run while the sqlite3 library is busy compiling SQL into
bytecode, or fetching the next row. This *does not* hurt Extralite's
performance, as you can see:
Extralite releases the GVL while making calls to the sqlite3 library that might
block, such as when backing up a database, or when preparing a query. Extralite
also releases the GVL periodically when iterating over records. By default, the
GVL is released every 1000 records iterated. The GVL release threshold can be
set separately for each database:

```ruby
db.gvl_release_threshold = 10 # release GVL every 10 records

db.gvl_release_threshold = nil # use default value (currently 1000)
```

For most applications, there's no need to tune the GVL threshold value, as it
provides [excellent](#performance) performance characteristics for both single-threaded and
multi-threaded applications.

In a heavily multi-threaded application, releasing the GVL more often (lower
threshold value) will lead to less latency (for threads not running a query),
but will also hurt the throughput (for the thread running the query). Releasing
the GVL less often (higher threshold value) will lead to better throughput for
queries, while increasing latency for threads not running a query. The following
diagram demonstrates the relationship between the GVL release threshold value,
latency and throughput:

```
less latency & throughput <<< GVL release threshold >>> more latency & throughput
```

## Performance

Expand Down
13 changes: 12 additions & 1 deletion ext/extralite/common.c
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,20 @@ void *stmt_iterate_step(void *ptr) {
return NULL;
}

inline enum gvl_mode stepwise_gvl_mode(query_ctx *ctx) {
// a negative or zero threshold means the GVL is always held during iteration.
if (ctx->gvl_release_threshold <= 0) return GVL_HOLD;

if (!sqlite3_stmt_busy(ctx->stmt)) return GVL_RELEASE;

// if positive, the GVL is normally held, and release every <threshold> steps.
return (ctx->step_count % ctx->gvl_release_threshold) ? GVL_HOLD : GVL_RELEASE;
}

inline int stmt_iterate(query_ctx *ctx) {
struct step_ctx step_ctx = {ctx->stmt, 0};
gvl_call(GVL_RELEASE, stmt_iterate_step, (void *)&step_ctx);
ctx->step_count += 1;
gvl_call(stepwise_gvl_mode(ctx), stmt_iterate_step, (void *)&step_ctx);
switch (step_ctx.rc) {
case SQLITE_ROW:
return 1;
Expand Down
42 changes: 40 additions & 2 deletions ext/extralite/database.c
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ VALUE Database_initialize(int argc, VALUE *argv, VALUE self) {
#endif

db->trace_block = Qnil;
db->gvl_release_threshold = DEFAULT_GVL_RELEASE_THRESHOLD;

return Qnil;
}
Expand Down Expand Up @@ -185,7 +186,7 @@ static inline VALUE Database_perform_query(int argc, VALUE *argv, VALUE self, VA
RB_GC_GUARD(sql);

bind_all_parameters(stmt, argc - 1, argv + 1);
query_ctx ctx = QUERY_CTX(self, db->sqlite3_db, stmt, Qnil, QUERY_MODE(QUERY_MULTI_ROW), ALL_ROWS);
query_ctx ctx = QUERY_CTX(self, db, stmt, Qnil, QUERY_MODE(QUERY_MULTI_ROW), ALL_ROWS);

return rb_ensure(SAFE(call), (VALUE)&ctx, SAFE(cleanup_stmt), (VALUE)&ctx);
}
Expand Down Expand Up @@ -362,7 +363,7 @@ VALUE Database_execute_multi(VALUE self, VALUE sql, VALUE params_array) {

// prepare query ctx
prepare_single_stmt(db->sqlite3_db, &stmt, sql);
query_ctx ctx = QUERY_CTX(self, db->sqlite3_db, stmt, params_array, QUERY_MULTI_ROW, ALL_ROWS);
query_ctx ctx = QUERY_CTX(self, db, stmt, params_array, QUERY_MULTI_ROW, ALL_ROWS);

return rb_ensure(SAFE(safe_execute_multi), (VALUE)&ctx, SAFE(cleanup_stmt), (VALUE)&ctx);
}
Expand Down Expand Up @@ -753,6 +754,41 @@ VALUE Database_inspect(VALUE self) {
}
}

/* Returns the database's GVL release threshold.
*
* @return [Integer] GVL release threshold
*/
VALUE Database_gvl_release_threshold_get(VALUE self) {
Database_t *db = self_to_open_database(self);
return INT2NUM(db->gvl_release_threshold);
}

/* Sets the database's GVL release threshold. To always hold the GVL while
* running a query, set the threshold to 0. To release the GVL on each record,
* set the threshold to 1. Larger values mean the GVL will be released less
* often, e.g. a value of 10 means the GVL will be released every 10 records
* iterated. A value of nil sets the threshold to the default value, which is
* currently 1000.
*
* @return [Integer, nil] New GVL release threshold
*/
VALUE Database_gvl_release_threshold_set(VALUE self, VALUE value) {
Database_t *db = self_to_open_database(self);

switch (TYPE(value)) {
case T_FIXNUM:
db->gvl_release_threshold = NUM2INT(value);
break;
case T_NIL:
db->gvl_release_threshold = DEFAULT_GVL_RELEASE_THRESHOLD;
break;
default:
rb_raise(eArgumentError, "Invalid GVL release threshold value (expect integer or nil)");
}

return INT2NUM(db->gvl_release_threshold);
}

void Init_ExtraliteDatabase(void) {
VALUE mExtralite = rb_define_module("Extralite");
rb_define_singleton_method(mExtralite, "runtime_status", Extralite_runtime_status, -1);
Expand All @@ -777,6 +813,8 @@ void Init_ExtraliteDatabase(void) {
rb_define_method(cDatabase, "execute", Database_execute, -1);
rb_define_method(cDatabase, "execute_multi", Database_execute_multi, 2);
rb_define_method(cDatabase, "filename", Database_filename, -1);
rb_define_method(cDatabase, "gvl_release_threshold", Database_gvl_release_threshold_get, 0);
rb_define_method(cDatabase, "gvl_release_threshold=", Database_gvl_release_threshold_set, 1);
rb_define_method(cDatabase, "initialize", Database_initialize, -1);
rb_define_method(cDatabase, "inspect", Database_inspect, 0);
rb_define_method(cDatabase, "interrupt", Database_interrupt, 0);
Expand Down
9 changes: 6 additions & 3 deletions ext/extralite/extralite.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ extern VALUE SYM_single_column;
typedef struct {
sqlite3 *sqlite3_db;
VALUE trace_block;
int gvl_release_threshold;
} Database_t;

typedef struct {
Expand Down Expand Up @@ -80,7 +81,8 @@ typedef struct {
enum query_mode mode;
int max_rows;
int eof;
int row_count;
int gvl_release_threshold;
int step_count;
} query_ctx;

enum gvl_mode {
Expand All @@ -92,8 +94,9 @@ enum gvl_mode {
#define SINGLE_ROW -2
#define QUERY_MODE(default) (rb_block_given_p() ? QUERY_YIELD : default)
#define MULTI_ROW_P(mode) (mode == QUERY_MULTI_ROW)
#define QUERY_CTX(self, sqlite3_db, stmt, params, mode, max_rows) \
{ self, sqlite3_db, stmt, params, mode, max_rows, 0, 0 }
#define QUERY_CTX(self, db, stmt, params, mode, max_rows) \
{ self, db->sqlite3_db, stmt, params, mode, max_rows, 0, db->gvl_release_threshold, 0 }
#define DEFAULT_GVL_RELEASE_THRESHOLD 1000

extern rb_encoding *UTF8_ENCODING;

Expand Down
4 changes: 2 additions & 2 deletions ext/extralite/query.c
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ static inline VALUE Query_perform_next(VALUE self, int max_rows, VALUE (*call)(q

query_ctx ctx = QUERY_CTX(
self,
query->sqlite3_db,
query->db_struct,
query->stmt,
Qnil,
QUERY_MODE(max_rows == SINGLE_ROW ? QUERY_SINGLE_ROW : QUERY_MULTI_ROW),
Expand Down Expand Up @@ -391,7 +391,7 @@ VALUE Query_execute_multi(VALUE self, VALUE parameters) {

query_ctx ctx = QUERY_CTX(
self,
query->sqlite3_db,
query->db_struct,
query->stmt,
parameters,
QUERY_MODE(QUERY_MULTI_ROW),
Expand Down
2 changes: 2 additions & 0 deletions test/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
require 'minitest/autorun'

puts "sqlite3 version: #{Extralite.sqlite3_version}"

IS_LINUX = RUBY_PLATFORM =~ /linux/
4 changes: 2 additions & 2 deletions test/issue-38.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require "sqlite3"
require "extralite"
require "./lib/extralite"
require "benchmark"

# Setup
Expand Down Expand Up @@ -41,7 +41,7 @@
# Benchmark variations

THREAD_COUNTS = [1, 2, 4, 8]
LIMITS = [1000]#[10, 100, 1000]
LIMITS = [10, 100, 1000]
CLIENTS = %w[extralite sqlite3]

# Benchmark
Expand Down
9 changes: 6 additions & 3 deletions test/perf_ary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@
require 'benchmark/ips'
require 'fileutils'

DB_PATH = '/tmp/extralite_sqlite3_perf.db'
DB_PATH = "/tmp/extralite_sqlite3_perf-#{Time.now.to_i}-#{rand(10000)}.db"
puts "DB_PATH = #{DB_PATH.inspect}"


def prepare_database(count)
FileUtils.rm(DB_PATH) rescue nil
db = Extralite::Database.new(DB_PATH)
db.query('create table foo ( a integer primary key, b text )')
db.query('create table if not exists foo ( a integer primary key, b text )')
db.query('delete from foo')
db.query('begin')
count.times { db.query('insert into foo (b) values (?)', "hello#{rand(1000)}" )}
db.query('commit')
db.close
end

def sqlite3_run(count)
Expand Down
11 changes: 7 additions & 4 deletions test/perf_hash.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@
require 'benchmark/ips'
require 'fileutils'

DB_PATH = '/tmp/extralite_sqlite3_perf.db'
DB_PATH = "/tmp/extralite_sqlite3_perf-#{Time.now.to_i}-#{rand(10000)}.db"
puts "DB_PATH = #{DB_PATH.inspect}"

def prepare_database(count)
FileUtils.rm(DB_PATH) rescue nil
db = Extralite::Database.new(DB_PATH)
db.query('create table foo ( a integer primary key, b text )')
db.query('create table if not exists foo ( a integer primary key, b text )')
db.query('delete from foo')
db.query('begin')
count.times { db.query('insert into foo (b) values (?)', "hello#{rand(1000)}" )}
db.query('commit')
db.close
end

def sqlite3_run(count)
Expand All @@ -36,7 +38,7 @@ def extralite_run(count)
end

[10, 1000, 100000].each do |c|
puts; puts; puts "Record count: #{c}"
puts "Record count: #{c}"

prepare_database(c)

Expand All @@ -48,4 +50,5 @@ def extralite_run(count)

x.compare!
end
puts; puts;
end
97 changes: 97 additions & 0 deletions test/test_database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -677,3 +677,100 @@ def test_backup_with_fn
assert_equal [[1, 2, 3], [4, 5, 6]], db.query_ary('select * from t')
end
end

class GVLReleaseThresholdTest < Minitest::Test
def setup
@sql = <<~SQL
WITH RECURSIVE r(i) AS (
VALUES(0)
UNION ALL
SELECT i FROM r
LIMIT 3000000
)
SELECT i FROM r WHERE i = 1;
SQL
end

def test_default_gvl_release_threshold
db = Extralite::Database.new(':memory:')
assert_equal 1000, db.gvl_release_threshold
end

def test_gvl_always_release
skip if !IS_LINUX

delays = []
running = true
t1 = Thread.new do
last = Time.now
while running
sleep 0.1
now = Time.now
delays << (now - last)
last = now
end
end
t2 = Thread.new do
db = Extralite::Database.new(':memory:')
db.gvl_release_threshold = 1
db.query(@sql)
ensure
running = false
end
t2.join
t1.join

assert delays.size > 4
assert_equal 0, delays.select { |d| d > 0.15 }.size
end

def test_gvl_always_hold
skip if !IS_LINUX

delays = []
running = true

signal = Queue.new
db = Extralite::Database.new(':memory:')
db.gvl_release_threshold = 0

t1 = Thread.new do
last = Time.now
while running
signal << true
sleep 0.1
now = Time.now
delays << (now - last)
last = now
end
end

t2 = Thread.new do
signal.pop
db.query(@sql)
ensure
running = false
end
t2.join
t1.join

assert delays.size >= 1
assert delays.first > 0.2
end

def test_gvl_mode_get_set
db = Extralite::Database.new(':memory:')
assert_equal 1000, db.gvl_release_threshold

db.gvl_release_threshold = 42
assert_equal 42, db.gvl_release_threshold

db.gvl_release_threshold = 0
assert_equal 0, db.gvl_release_threshold

assert_raises(ArgumentError) { db.gvl_release_threshold = :foo }

db.gvl_release_threshold = nil
assert_equal 1000, db.gvl_release_threshold
end
end

0 comments on commit fef32b6

Please sign in to comment.