Skip to content

Commit

Permalink
Implement GVL release threshold (#46)
Browse files Browse the repository at this point in the history
* Refactor gvl-related calls, add row count to query ctx

* Implement GVL release threshold
  • Loading branch information
noteflakes authored Dec 24, 2023
1 parent ee9e7cf commit aca1c43
Show file tree
Hide file tree
Showing 10 changed files with 242 additions and 37 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
32 changes: 26 additions & 6 deletions ext/extralite/common.c
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@

rb_encoding *UTF8_ENCODING;

inline void *gvl_call(enum gvl_mode mode, void *(*fn)(void *), void *data) {
switch (mode) {
case GVL_RELEASE:
return rb_thread_call_without_gvl(fn, data, RUBY_UBF_IO, 0);
default:
return fn(data);
}
}

static inline VALUE get_column_value(sqlite3_stmt *stmt, int col, int type) {
switch (type) {
case SQLITE_NULL:
Expand Down Expand Up @@ -150,7 +159,7 @@ typedef struct {
int rc;
} prepare_stmt_ctx;

void *prepare_multi_stmt_without_gvl(void *ptr) {
void *prepare_multi_stmt_impl(void *ptr) {
prepare_stmt_ctx *ctx = (prepare_stmt_ctx *)ptr;
const char *rest = NULL;
const char *str = ctx->str;
Expand Down Expand Up @@ -187,7 +196,7 @@ is not executed, but instead handed back to the caller for looping over results.
*/
void prepare_multi_stmt(sqlite3 *db, sqlite3_stmt **stmt, VALUE sql) {
prepare_stmt_ctx ctx = {db, stmt, RSTRING_PTR(sql), RSTRING_LEN(sql), 0};
rb_thread_call_without_gvl(prepare_multi_stmt_without_gvl, (void *)&ctx, RUBY_UBF_IO, 0);
gvl_call(GVL_RELEASE, prepare_multi_stmt_impl, (void *)&ctx);
RB_GC_GUARD(sql);

switch (ctx.rc) {
Expand All @@ -204,7 +213,7 @@ void prepare_multi_stmt(sqlite3 *db, sqlite3_stmt **stmt, VALUE sql) {

#define SQLITE_MULTI_STMT -1

void *prepare_single_stmt_without_gvl(void *ptr) {
void *prepare_single_stmt_impl(void *ptr) {
prepare_stmt_ctx *ctx = (prepare_stmt_ctx *)ptr;
const char *rest = NULL;
const char *str = ctx->str;
Expand All @@ -229,7 +238,7 @@ void *prepare_single_stmt_without_gvl(void *ptr) {

void prepare_single_stmt(sqlite3 *db, sqlite3_stmt **stmt, VALUE sql) {
prepare_stmt_ctx ctx = {db, stmt, RSTRING_PTR(sql), RSTRING_LEN(sql), 0};
rb_thread_call_without_gvl(prepare_single_stmt_without_gvl, (void *)&ctx, RUBY_UBF_IO, 0);
gvl_call(GVL_RELEASE, prepare_single_stmt_impl, (void *)&ctx);
RB_GC_GUARD(sql);

switch (ctx.rc) {
Expand All @@ -251,15 +260,26 @@ struct step_ctx {
int rc;
};

void *stmt_iterate_without_gvl(void *ptr) {
void *stmt_iterate_step(void *ptr) {
struct step_ctx *ctx = (struct step_ctx *)ptr;
ctx->rc = sqlite3_step(ctx->stmt);
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};
rb_thread_call_without_gvl(stmt_iterate_without_gvl, (void *)&step_ctx, RUBY_UBF_IO, 0);
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
52 changes: 45 additions & 7 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,8 +186,8 @@ 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 = { 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 = { self, db->sqlite3_db, stmt, params_array, QUERY_MODE(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 @@ -487,13 +488,13 @@ typedef struct {
#define BACKUP_STEP_MAX_PAGES 16
#define BACKUP_SLEEP_MS 100

void *backup_step_without_gvl(void *ptr) {
void *backup_step_impl(void *ptr) {
backup_ctx *ctx = (backup_ctx *)ptr;
ctx->rc = sqlite3_backup_step(ctx->backup, BACKUP_STEP_MAX_PAGES);
return NULL;
}

void *backup_sleep_without_gvl(void *unused) {
void *backup_sleep_impl(void *unused) {
sqlite3_sleep(BACKUP_SLEEP_MS);
return NULL;
}
Expand All @@ -503,7 +504,7 @@ VALUE backup_safe_iterate(VALUE ptr) {
int done = 0;

while (!done) {
rb_thread_call_without_gvl(backup_step_without_gvl, (void *)ctx, RUBY_UBF_IO, 0);
gvl_call(GVL_RELEASE, backup_step_impl, (void *)ctx);
switch(ctx->rc) {
case SQLITE_DONE:
if (ctx->block_given) {
Expand All @@ -521,7 +522,7 @@ VALUE backup_safe_iterate(VALUE ptr) {
continue;
case SQLITE_BUSY:
case SQLITE_LOCKED:
rb_thread_call_without_gvl(backup_sleep_without_gvl, NULL, RUBY_UBF_IO, 0);
gvl_call(GVL_RELEASE, backup_sleep_impl, NULL);
continue;
default:
rb_raise(cError, "%s", sqlite3_errstr(ctx->rc));
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
13 changes: 13 additions & 0 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,12 +81,22 @@ typedef struct {
enum query_mode mode;
int max_rows;
int eof;
int gvl_release_threshold;
int step_count;
} query_ctx;

enum gvl_mode {
GVL_RELEASE,
GVL_HOLD
};

#define ALL_ROWS -1
#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, 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 Expand Up @@ -120,4 +131,6 @@ VALUE cleanup_stmt(query_ctx *ctx);
sqlite3 *Database_sqlite3_db(VALUE self);
Database_t *self_to_database(VALUE self);

void *gvl_call(enum gvl_mode mode, void *(*fn)(void *), void *data);

#endif /* EXTRALITE_H */
18 changes: 12 additions & 6 deletions ext/extralite/query.c
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,14 @@ static inline VALUE Query_perform_next(VALUE self, int max_rows, VALUE (*call)(q
if (!query->stmt) query_reset(query);
if (query->eof) return rb_block_given_p() ? self : Qnil;

query_ctx ctx = {
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),
MAX_ROWS(max_rows),
0
};
MAX_ROWS(max_rows)
);
VALUE result = call(&ctx);
query->eof = ctx.eof;
return (ctx.mode == QUERY_YIELD) ? self : result;
Expand Down Expand Up @@ -390,7 +389,14 @@ VALUE Query_execute_multi(VALUE self, VALUE parameters) {
if (!query->stmt)
prepare_single_stmt(query->sqlite3_db, &query->stmt, query->sql);

query_ctx ctx = { self, query->sqlite3_db, query->stmt, parameters, QUERY_MODE(QUERY_MULTI_ROW), ALL_ROWS };
query_ctx ctx = QUERY_CTX(
self,
query->db_struct,
query->stmt,
parameters,
QUERY_MODE(QUERY_MULTI_ROW),
ALL_ROWS
);
return safe_execute_multi(&ctx);
}

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
Loading

0 comments on commit aca1c43

Please sign in to comment.