diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6fa953856..58da3087ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Full documentation for MIGraphX is available at ### Added -* Added a binary cache for compiled GPU kernels: identical kernels within a model compile once, and setting the `MIGRAPHX_BINARY_CACHE` environment variable (or the `binary_cache` backend option) also persists them on disk so later compiles of the same kernels skip compilation entirely; a `binary_cache_verify` backend option recompiles reused kernels and fails if they differ. +* Added a binary cache for compiled GPU kernels: identical kernels within a model compile once, and setting the `MIGRAPHX_BINARY_CACHE` environment variable (or the `binary_cache` backend option) also persists them on disk so later compiles of the same kernels skip compilation entirely, in a directory or, for a path ending in `.db` or `.sqlite`, in a single SQLite database; a `binary_cache_verify` backend option recompiles reused kernels and fails if they differ. ### Changed diff --git a/docs/dev/triage-migraphx.rst b/docs/dev/triage-migraphx.rst index d85d63d0dba..6f1dcac722b 100644 --- a/docs/dev/triage-migraphx.rst +++ b/docs/dev/triage-migraphx.rst @@ -175,6 +175,30 @@ directories for builds you no longer use: ls $HOME/.cache/migraphx # directories are named after the build that wrote them rm -r $HOME/.cache/migraphx/v1-hip22.0.* +A path ending in ``.db`` or ``.sqlite`` keeps the cache in a single SQLite database instead of a +directory, which is easier to copy between machines. Like a directory cache it can be used by +several processes at once, and missing parent directories are created: + +.. code-block:: bash + + export MIGRAPHX_BINARY_CACHE=$HOME/.cache/migraphx/kernels.db + +Every row records the full version id of the build that wrote it in the ``version`` column, +alongside the operator name, problem and solution, so the database can be inspected and pruned +with the ``sqlite3`` shell. Deleting rows does not shrink the file until it is vacuumed: + +.. code-block:: bash + + sqlite3 $HOME/.cache/migraphx/kernels.db \ + "SELECT version, op_name, count(*) FROM cache_v1 GROUP BY version, op_name;" + sqlite3 $HOME/.cache/migraphx/kernels.db \ + "DELETE FROM cache_v1 WHERE version GLOB 'v1-hip22.0.*'; VACUUM;" + +A database that cannot be written to, such as a shared cache installed read-only, is still used +for lookups, and newly compiled kernels are kept in memory only. A database that cannot be +opened, or whose cache table has an unexpected layout, is skipped with a warning and the compile +proceeds without a disk cache. + The same settings are available as backend options, which take precedence over the environment and are how tests configure the cache: diff --git a/src/include/migraphx/sqlite.hpp b/src/include/migraphx/sqlite.hpp index 97f3c0405f7..b5792a9afef 100644 --- a/src/include/migraphx/sqlite.hpp +++ b/src/include/migraphx/sqlite.hpp @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2015-2023 Advanced Micro Devices, Inc. All rights reserved. + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -25,8 +25,18 @@ #define MIGRAPHX_GUARD_MIGRAPHX_SQLITE_HPP #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include @@ -34,14 +44,159 @@ namespace migraphx { inline namespace MIGRAPHX_INLINE_NS { struct sqlite_impl; +struct sqlite_stmt_impl; + +/// A prepared statement. It shares ownership of the connection it was prepared on, which stays +/// open for as long as the statement exists. Copies share the same statement. +/// +/// Calling it with arguments runs it: the arguments are bound to the parameters in order and the +/// result comes back as a range of rows. Since copies share one statement, only the rows of one +/// call may be alive at a time. +/// +/// Not thread safe: use a statement from one thread at a time. +struct MIGRAPHX_EXPORT sqlite_stmt +{ + /// The rows produced by one call of a statement, as an input range of values. + /// + /// The first row is fetched when the call is made, so a statement that returns nothing, such + /// as an insert, has already run by the time the call returns, whether or not the range is + /// iterated. The statement is reset when the range is destroyed: an unfinished select holds + /// a read lock on the database until then, which would stall writers in other processes. + /// + /// Refers to the statement it came from, which must outlive it. + struct rows + { + // Only ever a prvalue returned from a call, so it never needs copying or moving, and a + // copy would reset the statement out from under the original. + rows(const rows&) = delete; + rows(rows&&) = delete; + rows& operator=(const rows&) = delete; + rows& operator=(rows&&) = delete; + ~rows() { stmt->reset(); } + + struct iterator : iterator_operators + { + using value_type = value; + using reference = value_type; + using difference_type = std::ptrdiff_t; + using iterator_category = std::input_iterator_tag; + using pointer = value*; + + iterator() = default; + + iterator(const rows* pparent, bool pavailable) : parent(pparent), available(pavailable) + { + } + + reference operator*() const + { + assert(parent != nullptr and available); + return parent->stmt->to_value(); + } + + static void increment(iterator& x) + { + assert(x.parent != nullptr and x.available); + x.available = x.parent->stmt->step(); + } + + static bool equal(const iterator& x, const iterator& y) + { + return x.parent == y.parent and x.available == y.available; + } + + private: + const rows* parent = nullptr; + bool available = false; + }; + + iterator begin() const { return {this, first}; } + iterator end() const { return {this, false}; } + + private: + friend struct sqlite_stmt; + + explicit rows(const sqlite_stmt& s) : stmt(&s) + { + // The destructor does not run when the constructor throws, so a failed first step + // resets the statement here instead. + try + { + first = stmt->step(); + } + catch(...) + { + stmt->reset(); + throw; + } + } + + const sqlite_stmt* stmt = nullptr; + bool first = false; + }; + + sqlite_stmt() = default; + + /// Run the statement with xs bound to its parameters in order, and return its rows. + template + rows operator()(const Ts&... xs) const + { + if(not valid()) + MIGRAPHX_THROW("sqlite: calling a statement that was never prepared"); + assert(sizeof...(Ts) == parameter_count()); + // Anything left from the previous call, bindings or an unfinished result, goes first. + reset(); + int i = 0; + each_args([&](const auto& x) { bind(++i, x); }, xs...); + return rows{*this}; + } + + bool valid() const { return impl != nullptr; } + + private: + // Parameter indices are 1-based, matching sqlite's own convention. + void bind(int i, std::string_view s) const; + void bind(int i, std::int64_t x) const; + void bind(int i, const std::vector& blob) const; + + std::size_t parameter_count() const; + + /// Step once. True when a row is available, false when the statement is done. + bool step() const; + + /// Clear bindings and rewind, so the statement can be used again. Safe at any point, + /// including after step() has thrown. + void reset() const noexcept; + + /// The current row as an object keyed by column name. Blobs become value::binary and SQL + /// NULL becomes a null value. + value to_value() const; + + friend struct sqlite; + std::shared_ptr impl; +}; struct MIGRAPHX_EXPORT sqlite { sqlite() = default; static sqlite read(const fs::path& p); static sqlite write(const fs::path& p); + + /// Open for writing, or nullopt if the file cannot be opened or created. For callers + /// that treat an unusable database as "no cache" rather than as an error. + static optional try_write(const fs::path& p); + std::vector> execute(const std::string& s); + sqlite_stmt prepare(const std::string& sql); + + /// How long to wait for a lock held by another connection before failing. + void set_busy_timeout(int ms); + + /// True when writes will be refused. Opening for writing still succeeds on a file the OS + /// has write-protected, in which case sqlite quietly opens it read-only; this is how to tell. + bool read_only() const; + private: std::shared_ptr impl; }; diff --git a/src/include/migraphx/value.hpp b/src/include/migraphx/value.hpp index 70ce5613fde..f81e694654d 100644 --- a/src/include/migraphx/value.hpp +++ b/src/include/migraphx/value.hpp @@ -297,6 +297,16 @@ struct MIGRAPHX_EXPORT value value(const std::pair& p) : value(p.first, p.second) { } + + /// A keyed value. Braces would select the initializer_list constructor and make a two-element + /// array instead, so returning this spares callers from suppressing the tidy check that + /// suggests them. + template + static value pair(const std::string& pkey, Ts&&... xs) + { + // NOLINTNEXTLINE(modernize-return-braced-init-list) + return value(pkey, static_cast(xs)...); + } template {})> value& operator=(T rhs) { diff --git a/src/sqlite.cpp b/src/sqlite.cpp index 823d74c3a77..f5cf58219ca 100644 --- a/src/sqlite.cpp +++ b/src/sqlite.cpp @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2015-2023 Advanced Micro Devices, Inc. All rights reserved. + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -27,6 +27,8 @@ #include #include #include +#include +#include namespace migraphx { inline namespace MIGRAPHX_INLINE_NS { @@ -36,12 +38,20 @@ using sqlite3_ptr = MIGRAPHX_MANAGE_PTR(sqlite3*, sqlite3_close); struct sqlite_impl { sqlite3* get() const { return ptr.get(); } - void open(const fs::path& p, int flags) + + // sqlite3_open_v2 returns a handle even on failure (it carries the error message), so ptr + // takes ownership either way. + bool try_open(const fs::path& p, int flags) { sqlite3* ptr_tmp = nullptr; int rc = sqlite3_open_v2(p.string().c_str(), &ptr_tmp, flags, nullptr); ptr = sqlite3_ptr{ptr_tmp}; - if(rc != 0) + return rc == 0; + } + + void open(const fs::path& p, int flags) + { + if(not try_open(p, flags)) MIGRAPHX_THROW("error opening " + p.string() + ": " + error_message()); } @@ -74,6 +84,21 @@ struct sqlite_impl sqlite3_ptr ptr; }; +using sqlite3_stmt_ptr = MIGRAPHX_MANAGE_PTR(sqlite3_stmt*, sqlite3_finalize); + +struct sqlite_stmt_impl +{ + sqlite3_stmt* get() const { return ptr.get(); } + std::string error_message() const { return db->error_message(); } + + // Holding the connection keeps it alive while any statement on it exists. ptr is declared + // after db so it is finalized first; finalizing after the connection closes is undefined. + std::shared_ptr db; + sqlite3_stmt_ptr ptr; +}; + +constexpr int write_flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + sqlite sqlite::read(const fs::path& p) { sqlite r; @@ -86,8 +111,16 @@ sqlite sqlite::write(const fs::path& p) { sqlite r; r.impl = std::make_shared(); - // Using '+' instead of bitwise '|' to avoid compilation warning - r.impl->open(p, SQLITE_OPEN_READWRITE + SQLITE_OPEN_CREATE); + r.impl->open(p, write_flags); + return r; +} + +optional sqlite::try_write(const fs::path& p) +{ + sqlite r; + r.impl = std::make_shared(); + if(not r.impl->try_open(p, write_flags)) + return nullopt; return r; } @@ -108,5 +141,129 @@ std::vector> sqlite::execute(const return result; } +sqlite_stmt sqlite::prepare(const std::string& sql) +{ + assert(impl != nullptr); + sqlite3_stmt* stmt_tmp = nullptr; + int rc = sqlite3_prepare_v2(impl->get(), sql.c_str(), -1, &stmt_tmp, nullptr); + sqlite_stmt result; + result.impl = std::make_shared(); + result.impl->db = impl; + result.impl->ptr = sqlite3_stmt_ptr{stmt_tmp}; + if(rc != SQLITE_OK) + MIGRAPHX_THROW("error preparing '" + sql + "': " + impl->error_message()); + // sqlite succeeds without a statement for text that holds none, such as only a comment, and + // a statement with no handle would pass null into sqlite on its first call. + if(stmt_tmp == nullptr) + MIGRAPHX_THROW("error preparing '" + sql + "': no statement in the text"); + return result; +} + +void sqlite::set_busy_timeout(int ms) +{ + assert(impl != nullptr); + sqlite3_busy_timeout(impl->get(), ms); +} + +bool sqlite::read_only() const +{ + assert(impl != nullptr); + return sqlite3_db_readonly(impl->get(), "main") == 1; +} + +void sqlite_stmt::bind(int i, std::string_view s) const +{ + // A default-constructed string_view has null data(), and a null pointer binds SQL NULL + // rather than an empty string, so empty input substitutes a valid pointer. SQLITE_TRANSIENT + // makes sqlite take its own copy before returning, which is what lets callers bind + // temporaries. + const char* text = s.empty() ? "" : s.data(); + int rc = sqlite3_bind_text64(impl->get(), i, text, s.size(), SQLITE_TRANSIENT, SQLITE_UTF8); + if(rc != SQLITE_OK) + MIGRAPHX_THROW(impl->error_message()); +} + +void sqlite_stmt::bind(int i, std::int64_t x) const +{ + int rc = sqlite3_bind_int64(impl->get(), i, x); + if(rc != SQLITE_OK) + MIGRAPHX_THROW(impl->error_message()); +} + +void sqlite_stmt::bind(int i, const std::vector& blob) const +{ + // As with text, an empty vector's data() may be null, which would bind SQL NULL; a + // zero-length zeroblob is an empty BLOB instead. The 64-bit form is used because the + // plain one takes the size as an int, and SQLITE_TRANSIENT copies before returning so + // callers can bind temporaries. + int rc = blob.empty() + ? sqlite3_bind_zeroblob(impl->get(), i, 0) + : sqlite3_bind_blob64(impl->get(), i, blob.data(), blob.size(), SQLITE_TRANSIENT); + if(rc != SQLITE_OK) + MIGRAPHX_THROW(impl->error_message()); +} + +std::size_t sqlite_stmt::parameter_count() const +{ + return sqlite3_bind_parameter_count(impl->get()); +} + +bool sqlite_stmt::step() const +{ + int rc = sqlite3_step(impl->get()); + if(rc == SQLITE_ROW) + return true; + if(rc == SQLITE_DONE) + return false; + MIGRAPHX_THROW(impl->error_message()); +} + +void sqlite_stmt::reset() const noexcept +{ + assert(impl != nullptr); + // The return of sqlite3_reset is the error from the preceding step(), which the caller + // has already seen as a throw. There is nothing new to report, and this must not throw. + (void)sqlite3_reset(impl->get()); + (void)sqlite3_clear_bindings(impl->get()); +} + +/// Column i of the current row, keyed by its name. +static value column_value(sqlite3_stmt* stmt, int i) +{ + // Null only when sqlite runs out of memory, which would make the string below undefined. + const char* name = sqlite3_column_name(stmt, i); + assert(name != nullptr); + auto type = sqlite3_column_type(stmt, i); + switch(type) + { + case SQLITE_INTEGER: return value::pair(name, std::int64_t{sqlite3_column_int64(stmt, i)}); + case SQLITE_FLOAT: return value::pair(name, sqlite3_column_double(stmt, i)); + case SQLITE_TEXT: + case SQLITE_BLOB: { + // The data must be fetched before sqlite3_column_bytes: the other order can force a + // conversion that invalidates the pointer. Text comes back unchanged through + // sqlite3_column_blob, and a zero-length value as a null pointer, which means empty. + const auto* data = static_cast(sqlite3_column_blob(stmt, i)); + auto bytes = sqlite3_column_bytes(stmt, i); + assert(bytes >= 0); + std::size_t size = data == nullptr ? 0 : bytes; + if(type == SQLITE_TEXT) + return value::pair(name, size == 0 ? std::string{} : std::string(data, size)); + return value::pair(name, value::binary{data, size}); + } + default: return value::pair(name, nullptr); + } +} + +value sqlite_stmt::to_value() const +{ + auto* stmt = impl->get(); + value columns = value::object{}; + const int n = sqlite3_column_count(stmt); + for(int i = 0; i < n; ++i) + columns.insert(column_value(stmt, i)); + return columns; +} + } // namespace MIGRAPHX_INLINE_NS } // namespace migraphx diff --git a/src/targets/gpu/CMakeLists.txt b/src/targets/gpu/CMakeLists.txt index f4cf5524bfe..5ca9ab4f830 100644 --- a/src/targets/gpu/CMakeLists.txt +++ b/src/targets/gpu/CMakeLists.txt @@ -271,6 +271,7 @@ add_library(migraphx_gpu device_description.cpp device_name.cpp eliminate_data_type_for_gpu.cpp + file_binary_cache.cpp fixed_pad.cpp fuse_ck.cpp fuse_mlir.cpp @@ -300,6 +301,7 @@ add_library(migraphx_gpu problem_cache.cpp rocblas.cpp schedule_model.cpp + sqlite_binary_cache.cpp sqlite_problem_cache.cpp sync_device.cpp target.cpp diff --git a/src/targets/gpu/binary_cache.cpp b/src/targets/gpu/binary_cache.cpp index 44266be5a6e..35a660a2be6 100644 --- a/src/targets/gpu/binary_cache.cpp +++ b/src/targets/gpu/binary_cache.cpp @@ -22,17 +22,15 @@ * THE SOFTWARE. */ #include +#include +#include #include #include -#include -#include #include #include -#include -#include #include -#include #include +#include #include namespace migraphx { @@ -106,52 +104,31 @@ static std::string device_dir(const context& ctx) "_wf" + std::to_string(device.get_wavefront_size()); } -/// Where an entry lives, or an empty path when the toolchain cannot be identified and entries -/// from different toolchains would be indistinguishable. -static fs::path entry_path(const fs::path& root, const context& ctx, const std::string& key) -{ - const auto& version = binary_cache::version_id(true); - if(version.empty()) - return {}; - return root / version / device_dir(ctx) / (md5(key) + ".mxr"); -} +binary_cache::binary_cache(binary_cache_settings s) : settings(std::move(s)) {} -/// Publish by rename so a reader never sees a half-written file. The temporary stays beside -/// the destination since the rename is only atomic within one filesystem. -static void write_atomically(const fs::path& dest, const std::vector& content) +// The storage backend is selected by file type, the same rule make_problem_cache_backend applies +// in problem_cache.cpp: a ".db"/".sqlite" path is a SQLite database, anything else is a +// directory of entries. A directory is named with the short version id to keep paths short; a +// database records the full id, which is self-describing. Nothing is persisted when the compiler +// cannot be identified, since entries from different toolchains would be indistinguishable. +binary_cache_backend* binary_cache::get_backend() { - tmp_dir td{"cache", dest.parent_path()}; - auto tmp = td.path / dest.filename(); - write_buffer(tmp, content); - fs::rename(tmp, dest); -} - -/// Read the entry for a key off disk. Any failure is just a miss, so a damaged entry costs a -/// recompile and is written over. -static optional -read_entry(const fs::path& root, const context& ctx, const std::string& key) -{ - if(root.empty()) - return nullopt; - auto path = entry_path(root, ctx, key); - if(path.empty() or not fs::exists(path)) - return nullopt; - binary_cache::entry e; - try - { - migraphx::from_value(from_msgpack(read_buffer(path)), e); - } - catch(const std::exception& ex) - { - log::warn() << "Ignoring unreadable binary cache entry " << path << ": " << ex.what(); - return nullopt; - } - if(e.key != key) - { - log::warn() << "Ignoring binary cache entry with mismatched key: " << path; - return nullopt; - } - return e; + if(backend_opened) + return backend.has_value() ? &*backend : nullptr; + backend_opened = true; + const auto& path = settings.path; + // Checked first so that a memory-only cache never compiles the version probe. + if(path.empty()) + return nullptr; + const bool database = ends_with(path, ".db") or ends_with(path, ".sqlite"); + version = version_id(not database); + if(version.empty()) + return nullptr; + if(not database) + backend = binary_cache_backend{file_binary_cache{path}}; + else if(auto db = sqlite_binary_cache::open(path)) + backend = binary_cache_backend{std::move(*db)}; + return backend.has_value() ? &*backend : nullptr; } optional binary_cache::get(const context& ctx, const std::string& key) @@ -164,38 +141,41 @@ optional binary_cache::get(const context& ctx, const std::string& counters.reused++; return it->second; } - auto e = read_entry(settings.path, ctx, key); - if(not e.has_value()) + if(auto* b = get_backend()) { - counters.misses++; - return nullopt; + auto e = b->load(version, device_dir(ctx), key); + if(e.has_value()) + { + counters.hits++; + return memo.emplace(key, std::move(e->code)).first->second; + } } - counters.hits++; - return memo.emplace(key, std::move(e->code)).first->second; + counters.misses++; + return nullopt; } -void binary_cache::insert(const context& ctx, entry e) +void binary_cache::insert(const context& ctx, std::vector es) { - if(e.key.empty()) + es.erase(std::remove_if(es.begin(), es.end(), [](const entry& e) { return e.key.empty(); }), + es.end()); + if(es.empty()) return; - counters.compiled++; - const auto& root = settings.path; - auto path = root.empty() ? fs::path{} : entry_path(root, ctx, e.key); - if(not path.empty()) + counters.compiled += es.size(); + if(auto* b = get_backend()) { - // The content is decided entirely by the key, so a writer that loses the publish race - // replaces the file with the same bytes and no locking is needed. try { - fs::create_directories(path.parent_path()); - write_atomically(path, to_msgpack(migraphx::to_value(e))); + // A failure to store is a warning, not a failed compile. + b->store(version, device_dir(ctx), es); } catch(const std::exception& ex) { - log::warn() << "Failed to write binary cache entry " << path << ": " << ex.what(); + log::warn() << "Failed to store " << es.size() + << " binary cache entries: " << ex.what(); } } - memo[std::move(e.key)] = std::move(e.code); + for(auto& e : es) + memo[std::move(e.key)] = std::move(e.code); } } // namespace gpu diff --git a/src/targets/gpu/compile_ops.cpp b/src/targets/gpu/compile_ops.cpp index d70ad2cc604..7a2cb2acfac 100644 --- a/src/targets/gpu/compile_ops.cpp +++ b/src/targets/gpu/compile_ops.cpp @@ -217,23 +217,22 @@ static optional cache_lookup(context& ctx, const std::string& return cr; } -/// Record a freshly compiled result, under the same restriction as cache_lookup. -static void cache_store(context& ctx, - const operation& preop, - const value& solution, - const std::string& key, - const value& problem, - const compiled_code& code) +/// What to record for a freshly compiled result, or nullopt when its key must not be cached. +static optional make_cache_entry(const operation& preop, + const value& solution, + const std::string& key, + const value& problem, + const compiled_code& code) { if(is_private_key(key)) - return; + return nullopt; binary_cache::entry e; e.key = key; e.op_name = preop.name(); e.problem = problem; e.solution = solution; e.code = code; - ctx.get_binary_cache().insert(ctx, std::move(e)); + return e; } /// Reuse an earlier result for this key, or compile and record one. For callers with a single @@ -252,7 +251,13 @@ static compiler_replace compile_cached(context& ctx, return *cached; } auto cr = compile_fragment(ctx, ins, preop, solution); - cache_store(ctx, preop, solution, key, problem, cr.code); + if(auto e = make_cache_entry(preop, solution, key, problem, cr.code)) + { + // Built by push_back, since an initializer list would copy the entry rather than move it. + std::vector es; + es.push_back(std::move(*e)); + ctx.get_binary_cache().insert(ctx, std::move(es)); + } return cr; } @@ -448,9 +453,10 @@ struct compile_plan return cache_lookup(*ctx, key); } - void store(const value& solution, const std::string& key, const compiled_code& code) const + optional + cache_entry(const value& solution, const std::string& key, const compiled_code& code) const { - cache_store(*ctx, preop, solution, key, config ? config->problem : value{}, code); + return make_cache_entry(preop, solution, key, config ? config->problem : value{}, code); } /// True when the cache was configured to check reused results against a fresh compile. @@ -783,6 +789,35 @@ struct compile_manager par_compile(cps.size(), [&](auto i) { cps[i].update_config(exhaustive); }); } + /// Store every compiled result in the binary cache. + static void + store_results(const std::vector>>& tasks) + { + if(tasks.empty()) + return; + // Every plan compiles with the same context, so the entries all go to one cache in a + // single insert, letting its storage commit them together rather than one at a time. + auto* ctx = tasks.front().first->ctx; + assert(std::all_of( + tasks.begin(), tasks.end(), [&](const auto& task) { return task.first->ctx == ctx; })); + std::vector entries; + entries.reserve(tasks.size()); + for(const auto& [cp, cell] : tasks) + { + if(not cell->result.has_value()) + continue; + // When verifying, reused results are stored again, harmlessly replacing each entry + // with an equivalent one. + if(auto e = cp->cache_entry(cell->solution, cell->key, cell->result->code)) + entries.push_back(std::move(*e)); + assert(not cell->result->code.empty()); + // Only the serializable code is used from here on; dropping the replace function + // releases what its closure holds and keeps it off other instructions. + cell->result->replace_fn = nullptr; + } + ctx->get_binary_cache().insert(*ctx, std::move(entries)); + } + /// Fill every cell's result, from the cache or by compiling, sharing one compile among /// the cells with the same key. void compile_cells() @@ -853,18 +888,7 @@ struct compile_manager cell->result = cp->run_compile(cell->solution); }); - for(const auto& [cp, cell] : tasks) - { - if(not cell->result.has_value()) - continue; - // When verifying, reused results are stored again, rewriting the same bytes - // harmlessly. - cp->store(cell->solution, cell->key, cell->result->code); - assert(not cell->result->code.empty()); - // Only the serializable code is used from here on; dropping the replace function - // releases what its closure holds and keeps it off other instructions. - cell->result->replace_fn = nullptr; - } + store_results(tasks); } void compile(module& m, bool is_root) diff --git a/src/targets/gpu/file_binary_cache.cpp b/src/targets/gpu/file_binary_cache.cpp new file mode 100644 index 00000000000..63731546b5b --- /dev/null +++ b/src/targets/gpu/file_binary_cache.cpp @@ -0,0 +1,122 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace migraphx { +inline namespace MIGRAPHX_INLINE_NS { +namespace gpu { + +static_assert(std::is_constructible{}, + "file_binary_cache must satisfy the binary_cache_backend concept"); + +/// The directory holding one device's entries for one toolchain. The caller guarantees a +/// non-empty version, so entries compiled by different toolchains can never land in the same one. +static fs::path +entry_dir(const fs::path& root, const std::string& version, const std::string& device) +{ + return root / version / device; +} + +/// Where an entry lives in its directory. The key is the whole compile source, so it is hashed to +/// keep the name short. +static fs::path entry_path(const fs::path& dir, const std::string& key) +{ + return dir / (md5(key) + ".mxr"); +} + +/// Publish by rename so a reader never sees a half-written file. The temporary stays beside +/// the destination since the rename is only atomic within one filesystem. Its short random +/// suffix keeps concurrent writers of the same entry apart without lengthening an already deep +/// path, which on Windows must stay under MAX_PATH for std::ofstream to open it. +static void write_atomically(const fs::path& dest, const std::vector& content) +{ + auto suffix = md5(unique_string("cache")).substr(0, 16); + auto tmp = dest.parent_path() / (dest.stem().string() + "." + suffix + ".tmp"); + try + { + write_buffer(tmp, content); + fs::rename(tmp, dest); + } + catch(...) + { + std::error_code ec; + fs::remove(tmp, ec); + throw; + } +} + +optional file_binary_cache::load(const std::string& version, + const std::string& device, + const std::string& key) const +{ + auto path = entry_path(entry_dir(root, version, device), key); + binary_cache_entry e; + try + { + if(not fs::exists(path)) + return nullopt; + migraphx::from_value(from_msgpack(read_buffer(path)), e); + } + catch(const std::exception& ex) + { + // An unreadable or damaged entry is a miss, which costs a recompile and nothing else. + log::warn() << "Ignoring unreadable binary cache entry " << path << ": " << ex.what(); + return nullopt; + } + // Files are named by a hash of the key, so the full key is checked here to make a collision + // a miss rather than a wrong kernel. + if(e.key != key) + { + log::warn() << "Ignoring binary cache entry with mismatched key: " << path; + return nullopt; + } + return e; +} + +void file_binary_cache::store(const std::string& version, + const std::string& device, + const std::vector& entries) const +{ + // Every entry shares one directory, so it is created once for the whole batch. + auto dir = entry_dir(root, version, device); + fs::create_directories(dir); + // The content is decided entirely by the key, so a writer that loses the publish race + // replaces the file with the same bytes and no locking is needed. + for(const auto& e : entries) + write_atomically(entry_path(dir, e.key), to_msgpack(migraphx::to_value(e))); +} + +} // namespace gpu +} // namespace MIGRAPHX_INLINE_NS +} // namespace migraphx diff --git a/src/targets/gpu/include/migraphx/gpu/binary_cache.hpp b/src/targets/gpu/include/migraphx/gpu/binary_cache.hpp index e1a90d49b06..9eac0dd1d01 100644 --- a/src/targets/gpu/include/migraphx/gpu/binary_cache.hpp +++ b/src/targets/gpu/include/migraphx/gpu/binary_cache.hpp @@ -26,13 +26,14 @@ #include #include +#include +#include #include #include -#include -#include #include #include #include +#include namespace migraphx { inline namespace MIGRAPHX_INLINE_NS { @@ -56,9 +57,9 @@ struct binary_cache_settings /** * Compiled kernels, keyed by a string describing what the compiler was given. * - * Results are held in memory for the life of the context and, when a cache directory is - * configured, written to disk so later runs can reuse them. Things outside the key, such as the - * compiler and the embedded kernel headers, are separated by the directory the entries live in. + * Results are held in memory for the life of the context and, when a cache path is configured, + * written to disk so later runs can reuse them. Things outside the key, such as the compiler and + * the embedded kernel headers, are separated by the version the entries are stored under. * * This is not thread safe, and deliberately so. Every key is known before any compile begins, so * the compile pass looks results up and stores them in serial passes on either side of its @@ -66,33 +67,15 @@ struct binary_cache_settings */ struct MIGRAPHX_GPU_EXPORT binary_cache { - /// What gets written to disk for one compiled kernel. The op name, problem and solution are - /// stored for offline inspection; only the key is checked when an entry is loaded. - struct entry - { - std::string key = {}; - std::string op_name = {}; - value problem = {}; - value solution = {}; - compiled_code code = {}; - - template - static auto reflect(Self& self, F f) - { - return pack(f(self.key, "key"), - f(self.op_name, "op_name"), - f(self.problem, "problem"), - f(self.solution, "solution"), - f(self.code, "code")); - } - }; + /// What gets stored for one compiled kernel; see binary_cache_entry.hpp. + using entry = binary_cache_entry; /// Counts of what the cache did. struct stats { /// Served from memory, from an earlier compile or disk read in this process. std::size_t reused = 0; - /// Served from the cache directory. + /// Served from the storage backend. std::size_t hits = 0; /// Not found, so the caller had to compile. std::size_t misses = 0; @@ -100,13 +83,16 @@ struct MIGRAPHX_GPU_EXPORT binary_cache std::size_t compiled = 0; }; - explicit binary_cache(binary_cache_settings s = {}) : settings(std::move(s)) {} + /// Nothing is opened here; storage is set up by the first lookup or insert, so a context that + /// never compiles never touches the disk or probes the compiler. + explicit binary_cache(binary_cache_settings s = {}); - /// Look up a key, consulting memory first and then the cache directory. + /// Look up a key, consulting memory first and then the storage backend. optional get(const context& ctx, const std::string& key); - /// Record a compiled result under its key. - void insert(const context& ctx, entry e); + /// Record compiled results under their keys. They are handed to the storage backend + /// together, so it can commit them at once rather than one at a time. + void insert(const context& ctx, std::vector es); /// True when reused results should be checked against a fresh compile. bool verify() const { return settings.verify; } @@ -115,14 +101,23 @@ struct MIGRAPHX_GPU_EXPORT binary_cache /// Identifies what this build can use: the entry format, the compiler, a digest of the /// embedded kernel headers, and the rocMLIR build. Digests are truncated when a short id is - /// requested; the short id names the cache directory. Empty when the compiler cannot be - /// identified, in which case nothing is read from or written to disk, since entries from - /// different compilers could not be told apart. + /// requested; the short id names the cache directory, and a database records the full one. + /// Empty when the compiler cannot be identified, in which case nothing is read from or + /// written to disk, since entries from different compilers could not be told apart. static const std::string& version_id(bool use_short_digest); private: + /// The storage backend, opened on first use, or null for a memory-only cache. + binary_cache_backend* get_backend(); + std::unordered_map memo; binary_cache_settings settings; + /// The version_id entries are stored under, in the form the backend uses. + std::string version; + /// Where entries are persisted, or empty for a memory-only cache. + optional backend; + /// Whether get_backend has already tried to open the backend, successfully or not. + bool backend_opened = false; stats counters; }; diff --git a/src/targets/gpu/include/migraphx/gpu/binary_cache_backend.hpp b/src/targets/gpu/include/migraphx/gpu/binary_cache_backend.hpp new file mode 100644 index 00000000000..0ded21c4c61 --- /dev/null +++ b/src/targets/gpu/include/migraphx/gpu/binary_cache_backend.hpp @@ -0,0 +1,382 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +// +// te.py DSL for migraphx::gpu::binary_cache_backend. +// +// The generated header lives at +// src/targets/gpu/include/migraphx/gpu/binary_cache_backend.hpp; regenerate it +// with `cd tools && python generate.py` (generate_all routes include/gpu/ inputs +// into the gpu target tree). Do not edit the generated header by hand. +// +// Any type T satisfies the binary_cache_backend concept if it provides the +// member functions listed below. The wrapper holds T by shared_ptr and forwards +// each call through a virtual dispatch, matching problem_cache_backend. +// +// Notes: +// * binary_cache_entry is defined in ; +// the include below pulls in its full definition. +// * Backends must be copyable: the wrapper shares T and clones it on a +// non-const call while the handle is shared. sqlite_binary_cache shares its +// connection across copies. +// +#ifndef MIGRAPHX_GUARD_GPU_BINARY_CACHE_BACKEND_HPP +#define MIGRAPHX_GUARD_GPU_BINARY_CACHE_BACKEND_HPP + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace migraphx { +inline namespace MIGRAPHX_INLINE_NS { +namespace gpu { + +#ifdef DOXYGEN + +/// Type-erased interface for binary-cache storage backends. +/// +/// A backend persists binary_cache_entry values to some medium (a directory of +/// files or a SQLite database), and decides for itself how to serialize them. +/// Entries are addressed by their key, scoped by two strings the caller has +/// already computed: +/// +/// * `version` -- binary_cache::version_id(), short for the directory +/// backend and full for the database, identifying the toolchain and the +/// embedded kernel sources that produced the entry. Never empty; the +/// caller skips persistence entirely when it is. +/// * `device` -- the GPU the entry was compiled for. +/// +/// A backend must keep entries with different scopes distinct rather than +/// overwriting across them. It may address entries by a hash of the key, for +/// instance to keep file names short, but must then check the full key when +/// loading so that a collision is a miss rather than a wrong kernel. +struct binary_cache_backend +{ + /// Return the entry stored for this key, or nullopt for a miss. + /// + /// nullopt also covers every failure: a missing file, an unreadable + /// database, a damaged entry, a permissions problem. A cache that cannot be + /// read is not an error, it is a cache miss, and the caller recompiles. + /// + /// Must not throw. + optional + load(const std::string& version, const std::string& device, const std::string& key); + + /// Persist every entry in `entries` under its key. The entries arrive + /// together so a backend can commit them at once, such as in one database + /// transaction, rather than one at a time. + /// + /// Overwriting an existing entry is expected and safe: the content is + /// decided entirely by the key, so a writer that loses a race replaces the + /// entry with an equivalent one. + /// + /// May throw: the caller reports a failed store as a warning. It costs a + /// recompile next run, nothing more, and the caller still keeps the results + /// in memory. A backend that throws must not leave anything locked. + void store(const std::string& version, + const std::string& device, + const std::vector& entries); +}; + +#else + +#ifdef TYPE_ERASED_DECLARATION + +// Type-erased interface for: +struct MIGRAPHX_EXPORT binary_cache_backend +{ + // + optional + load(const std::string& version, const std::string& device, const std::string& key); + // + void store(const std::string& version, + const std::string& device, + const std::vector& entries); +}; + +#else +// NOLINTBEGIN(performance-unnecessary-value-param) +struct binary_cache_backend +{ + private: + template + struct private_te_unwrap_reference + { + using type = PrivateDetailTypeErasedT; + }; + template + struct private_te_unwrap_reference> + { + using type = PrivateDetailTypeErasedT; + }; + template + using private_te_pure = typename std::remove_cv< + typename std::remove_reference::type>::type; + + template + using private_te_constraints_impl = + decltype(std::declval().load(std::declval(), + std::declval(), + std::declval()), + std::declval().store( + std::declval(), + std::declval(), + std::declval&>()), + void()); + + template + using private_te_constraints = private_te_constraints_impl< + typename private_te_unwrap_reference>::type>; + + public: + // Constructors + binary_cache_backend() = default; + + template , + typename = typename std::enable_if< + not std::is_same, + binary_cache_backend>{}>::type> + binary_cache_backend(PrivateDetailTypeErasedT&& value) + : private_detail_te_handle_mem_var( + std::make_shared< + private_detail_te_handle_type>>( + std::forward(value))) + { + } + + // Assignment + template , + typename = typename std::enable_if< + not std::is_same, + binary_cache_backend>{}>::type> + binary_cache_backend& operator=(PrivateDetailTypeErasedT && value) + { + using std::swap; + auto* derived = this->any_cast>(); + if(derived and private_detail_te_handle_mem_var.use_count() == 1) + { + *derived = std::forward(value); + } + else + { + binary_cache_backend rhs(value); + swap(private_detail_te_handle_mem_var, rhs.private_detail_te_handle_mem_var); + } + return *this; + } + + // Cast + template + PrivateDetailTypeErasedT* any_cast() + { + return this->type_id() == typeid(PrivateDetailTypeErasedT) + ? std::addressof(static_cast::type>&>( + private_detail_te_get_handle()) + .private_detail_te_value) + : nullptr; + } + + template + const typename std::remove_cv::type* any_cast() const + { + return this->type_id() == typeid(PrivateDetailTypeErasedT) + ? std::addressof(static_cast::type>&>( + private_detail_te_get_handle()) + .private_detail_te_value) + : nullptr; + } + + const std::type_info& type_id() const + { + if(private_detail_te_handle_empty()) + return typeid(std::nullptr_t); + else + return private_detail_te_get_handle().type(); + } + + optional + load(const std::string& version, const std::string& device, const std::string& key) + { + assert((*this).private_detail_te_handle_mem_var); + return (*this).private_detail_te_get_handle().load(version, device, key); + } + + void store(const std::string& version, + const std::string& device, + const std::vector& entries) + { + assert((*this).private_detail_te_handle_mem_var); + (*this).private_detail_te_get_handle().store(version, device, entries); + } + + friend bool is_shared(const binary_cache_backend& private_detail_x, + const binary_cache_backend& private_detail_y) + { + return private_detail_x.private_detail_te_handle_mem_var == + private_detail_y.private_detail_te_handle_mem_var; + } + + private: + struct private_detail_te_handle_base_type + { + virtual ~private_detail_te_handle_base_type() {} + virtual std::shared_ptr clone() const = 0; + virtual const std::type_info& type() const = 0; + + virtual optional + load(const std::string& version, const std::string& device, const std::string& key) = 0; + virtual void store(const std::string& version, + const std::string& device, + const std::vector& entries) = 0; + }; + + template + struct private_detail_te_handle_type : private_detail_te_handle_base_type + { + template + private_detail_te_handle_type( + PrivateDetailTypeErasedT value, + typename std::enable_if{}>::type* = nullptr) + : private_detail_te_value(value) + { + } + + template + private_detail_te_handle_type( + PrivateDetailTypeErasedT value, + typename std::enable_if{}, int>::type* = + nullptr) noexcept + : private_detail_te_value(std::move(value)) + { + } + + std::shared_ptr clone() const override + { + return std::make_shared(private_detail_te_value); + } + + const std::type_info& type() const override { return typeid(private_detail_te_value); } + + optional + load(const std::string& version, const std::string& device, const std::string& key) override + { + + return private_detail_te_value.load(version, device, key); + } + + void store(const std::string& version, + const std::string& device, + const std::vector& entries) override + { + + private_detail_te_value.store(version, device, entries); + } + + PrivateDetailTypeErasedT private_detail_te_value; + }; + + template + struct private_detail_te_handle_type> + : private_detail_te_handle_type + { + private_detail_te_handle_type(std::reference_wrapper ref) + : private_detail_te_handle_type(ref.get()) + { + } + }; + + bool private_detail_te_handle_empty() const + { + return private_detail_te_handle_mem_var == nullptr; + } + + const private_detail_te_handle_base_type& private_detail_te_get_handle() const + { + assert(private_detail_te_handle_mem_var != nullptr); + return *private_detail_te_handle_mem_var; + } + + private_detail_te_handle_base_type& private_detail_te_get_handle() + { + assert(private_detail_te_handle_mem_var != nullptr); + if(private_detail_te_handle_mem_var.use_count() > 1) + private_detail_te_handle_mem_var = private_detail_te_handle_mem_var->clone(); + return *private_detail_te_handle_mem_var; + } + + std::shared_ptr private_detail_te_handle_mem_var; +}; + +template +inline const ValueType* any_cast(const binary_cache_backend* x) +{ + return x->any_cast(); +} + +template +inline ValueType* any_cast(binary_cache_backend* x) +{ + return x->any_cast(); +} + +template +inline ValueType& any_cast(binary_cache_backend& x) +{ + auto* y = x.any_cast::type>(); + if(y == nullptr) + throw std::bad_cast(); + return *y; +} + +template +inline const ValueType& any_cast(const binary_cache_backend& x) +{ + const auto* y = x.any_cast::type>(); + if(y == nullptr) + throw std::bad_cast(); + return *y; +} +// NOLINTEND(performance-unnecessary-value-param) +#endif + +#endif + +} // namespace gpu +} // namespace MIGRAPHX_INLINE_NS +} // namespace migraphx + +#endif // MIGRAPHX_GUARD_GPU_BINARY_CACHE_BACKEND_HPP diff --git a/src/targets/gpu/include/migraphx/gpu/binary_cache_entry.hpp b/src/targets/gpu/include/migraphx/gpu/binary_cache_entry.hpp new file mode 100644 index 00000000000..13d54117264 --- /dev/null +++ b/src/targets/gpu/include/migraphx/gpu/binary_cache_entry.hpp @@ -0,0 +1,64 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ +#ifndef MIGRAPHX_GUARD_GPU_BINARY_CACHE_ENTRY_HPP +#define MIGRAPHX_GUARD_GPU_BINARY_CACHE_ENTRY_HPP + +#include +#include +#include +#include +#include + +namespace migraphx { +inline namespace MIGRAPHX_INLINE_NS { +namespace gpu { + +/// What gets stored for one compiled kernel. The op name, problem and solution are stored for +/// offline inspection; only the key is checked when an entry is loaded. +/// In its own header so binary_cache_backend.hpp can use it without a circular include. +struct binary_cache_entry +{ + std::string key = {}; + std::string op_name = {}; + value problem = {}; + value solution = {}; + compiled_code code = {}; + + template + static auto reflect(Self& self, F f) + { + return pack(f(self.key, "key"), + f(self.op_name, "op_name"), + f(self.problem, "problem"), + f(self.solution, "solution"), + f(self.code, "code")); + } +}; + +} // namespace gpu +} // namespace MIGRAPHX_INLINE_NS +} // namespace migraphx + +#endif // MIGRAPHX_GUARD_GPU_BINARY_CACHE_ENTRY_HPP diff --git a/src/targets/gpu/include/migraphx/gpu/file_binary_cache.hpp b/src/targets/gpu/include/migraphx/gpu/file_binary_cache.hpp new file mode 100644 index 00000000000..0e39e334fc7 --- /dev/null +++ b/src/targets/gpu/include/migraphx/gpu/file_binary_cache.hpp @@ -0,0 +1,57 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ +#ifndef MIGRAPHX_GUARD_GPU_FILE_BINARY_CACHE_HPP +#define MIGRAPHX_GUARD_GPU_FILE_BINARY_CACHE_HPP + +#include +#include +#include +#include +#include +#include + +namespace migraphx { +inline namespace MIGRAPHX_INLINE_NS { +namespace gpu { + +// A binary_cache_backend that keeps each entry as a msgpack file under a root directory, laid +// out ///.mxr. The version directory is named after the build +// that wrote it, so the tree is self-describing. +struct MIGRAPHX_GPU_EXPORT file_binary_cache +{ + optional + load(const std::string& version, const std::string& device, const std::string& key) const; + void store(const std::string& version, + const std::string& device, + const std::vector& entries) const; + + fs::path root = {}; +}; + +} // namespace gpu +} // namespace MIGRAPHX_INLINE_NS +} // namespace migraphx + +#endif // MIGRAPHX_GUARD_GPU_FILE_BINARY_CACHE_HPP diff --git a/src/targets/gpu/include/migraphx/gpu/sqlite_binary_cache.hpp b/src/targets/gpu/include/migraphx/gpu/sqlite_binary_cache.hpp new file mode 100644 index 00000000000..921c9e87343 --- /dev/null +++ b/src/targets/gpu/include/migraphx/gpu/sqlite_binary_cache.hpp @@ -0,0 +1,74 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ +#ifndef MIGRAPHX_GUARD_GPU_SQLITE_BINARY_CACHE_HPP +#define MIGRAPHX_GUARD_GPU_SQLITE_BINARY_CACHE_HPP + +#include +#include +#include +#include +#include +#include + +namespace migraphx { +inline namespace MIGRAPHX_INLINE_NS { +namespace gpu { + +// A binary_cache_backend that keeps entries as rows in a SQLite database, one row per +// (version, device, md5 of key), with each field of the entry in its own column so a cache can +// be inspected with SQL. The compiled code, a program fragment, is stored as a msgpack blob. +// +// Holding sqlite and sqlite_stmt by value does not leak the SQLite dependency into this +// target: migraphx/sqlite.hpp forward-declares both impl types and never includes sqlite3.h. +struct MIGRAPHX_GPU_EXPORT sqlite_binary_cache +{ + /// Open the database at path, creating the schema if it is writable. + /// + /// A database that can only be read serves lookups and ignores stores; it is used as it + /// stands, without creating the schema. Returns nullopt when the database cannot be opened + /// at all or entries cannot be looked up in it, so an unusable database leaves the cache + /// memory-only rather than raising an error. + static optional open(const std::string& path); + + optional + load(const std::string& version, const std::string& device, const std::string& key) const; + + /// Store the entries in one transaction, so they cost one commit rather than one each. A + /// failure rolls the whole transaction back, leaves the cache read-only and rethrows. + void store(const std::string& version, + const std::string& device, + const std::vector& entries); + + private: + sqlite db = {}; + sqlite_stmt get_stmt = {}; + sqlite_stmt store_stmt = {}; +}; + +} // namespace gpu +} // namespace MIGRAPHX_INLINE_NS +} // namespace migraphx + +#endif // MIGRAPHX_GUARD_GPU_SQLITE_BINARY_CACHE_HPP diff --git a/src/targets/gpu/sqlite_binary_cache.cpp b/src/targets/gpu/sqlite_binary_cache.cpp new file mode 100644 index 00000000000..7bf302713d1 --- /dev/null +++ b/src/targets/gpu/sqlite_binary_cache.cpp @@ -0,0 +1,236 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace migraphx { +inline namespace MIGRAPHX_INLINE_NS { +namespace gpu { + +static_assert(std::is_constructible{}, + "sqlite_binary_cache must satisfy the binary_cache_backend concept"); + +namespace { + +// How long to wait for a lock held by another process before giving up. This is the entire +// cross-process strategy: whatever still fails degrades to a recompile. +constexpr int busy_timeout_ms = 5000; + +// The table name carries the schema version, so an incompatible change is a new table that old +// binaries ignore rather than a migration. This is orthogonal to binary_cache_format, which +// versions how entries are serialized and reaches each row through the version column. +// +// Deliberately not WITHOUT ROWID, unlike the sibling table in sqlite_problem_cache: that clause +// stores the payload inside the index B-tree, which suits short JSON but not a whole serialized +// program fragment, which would spill into overflow chains hanging off the index. +// +// Rows are addressed by a hash of the key rather than the key itself, which is the whole compile +// source and would otherwise be stored a second time in the index. The full key is kept in its +// own column and checked on load, so a collision is a miss rather than a wrong kernel. +// +// The primary key leads with version so that dropping everything belonging to a superseded +// toolchain is a range scan rather than a full table scan. Point lookups bind all three and do +// not care about the order. +constexpr const char* schema_sql = R"__migraphx__( +CREATE TABLE IF NOT EXISTS cache_v1 ( + version TEXT NOT NULL, + device TEXT NOT NULL, + key_hash TEXT NOT NULL, + key TEXT NOT NULL, + op_name TEXT NOT NULL, + problem TEXT NOT NULL, + solution TEXT NOT NULL, + code BLOB NOT NULL, + timestamp INTEGER NOT NULL, + PRIMARY KEY (version, device, key_hash) +); +)__migraphx__"; + +constexpr const char* get_sql = "SELECT key, op_name, problem, solution, code FROM cache_v1" + " WHERE version = ?1 AND device = ?2 AND key_hash = ?3;"; + +// INSERT OR REPLACE is the analog of the file backend's publish-by-rename: the content is +// decided entirely by the key, so two processes compiling the same kernel is benign and the +// last writer wins with an equivalent row. The timestamp is computed by the database rather +// than the process so that rows written by different machines stay comparable. MIGraphX never +// reads it; it lets a cache be pruned by age. +constexpr const char* store_sql = + "INSERT OR REPLACE INTO cache_v1" + " (version, device, key_hash, key, op_name, problem, solution, code, timestamp)" + " VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, CAST(STRFTIME('%s','now') AS INTEGER));"; + +// Stores come in a burst after each round of compiles, and outside a transaction every one of +// them is its own commit, each waiting for the disk. IMMEDIATE takes the write lock up front, so +// a busy database is found out here, once, rather than partway through the stores; readers are +// not blocked until the commit itself. +constexpr const char* begin_sql = "BEGIN IMMEDIATE;"; +constexpr const char* commit_sql = "COMMIT;"; +constexpr const char* rollback_sql = "ROLLBACK;"; + +} // namespace + +optional sqlite_binary_cache::open(const std::string& path) +{ + sqlite_binary_cache r; + try + { + // sqlite will not create a missing parent directory, but the file backend does, so + // this keeps the two backends behaving the same on a fresh machine. A failure here is + // left to the open below, since an existing database may still be readable. + auto parent = fs::path{path}.parent_path(); + std::error_code ec; + if(not parent.empty()) + fs::create_directories(parent, ec); + + // A database that can be read but not written to is still worth having: reads serve + // hits and nothing is stored. Opening for writing already falls back to reading when the + // file itself is write-protected; reading is tried here for anything else that refuses + // a writer, such as a read-only mount, as long as there is a database to read. + auto db = sqlite::try_write(path); + if(not db.has_value() and fs::exists(path)) + db = sqlite::read(path); + if(not db.has_value()) + { + log::warn() << "Disabling the binary cache: cannot open " << path; + return nullopt; + } + r.db = std::move(*db); + r.db.set_busy_timeout(busy_timeout_ms); + if(r.db.read_only()) + { + log::warn() << "Binary cache at " << path << " is read-only"; + } + else + { + r.db.execute(schema_sql); + r.store_stmt = r.db.prepare(store_sql); + } + // Without a working lookup there is no cache, so this failure disables the backend. That + // includes a read-only database that was never given the schema. + r.get_stmt = r.db.prepare(get_sql); + } + catch(const std::exception& ex) + { + log::warn() << "Disabling the binary cache at " << path << ": " << ex.what(); + return nullopt; + } + return r; +} + +optional sqlite_binary_cache::load(const std::string& version, + const std::string& device, + const std::string& key) const +{ + auto key_hash = md5(key); + try + { + // The primary key makes this at most one row. + auto rows = get_stmt(version, device, key_hash); + auto it = rows.begin(); + if(it == rows.end()) + return nullopt; + auto row = *it; + // Rows are addressed by a hash of the key, so the full key is checked here to make a + // collision a miss rather than a wrong kernel. + if(row.at("key").get_string() != key) + { + log::warn() << "Ignoring binary cache entry with mismatched key: " << key_hash; + return nullopt; + } + binary_cache_entry e; + e.key = key; + e.op_name = row.at("op_name").get_string(); + e.problem = from_json_string(row.at("problem").get_string()); + e.solution = from_json_string(row.at("solution").get_string()); + const auto& code = row.at("code").get_binary(); + migraphx::from_value(from_msgpack(reinterpret_cast(code.data()), code.size()), + e.code); + return e; + } + catch(const std::exception& ex) + { + // A cache that cannot be read, or a damaged row, is a miss, which costs a recompile and + // nothing else. + log::warn() << "Ignoring unreadable binary cache entry " << key_hash << ": " << ex.what(); + return nullopt; + } +} + +void sqlite_binary_cache::store(const std::string& version, + const std::string& device, + const std::vector& entries) +{ + // Never prepared for a read-only database, and cleared after a failed store; either way + // stores are skipped. + if(not store_stmt.valid()) + return; + try + { + db.execute(begin_sql); + for(const auto& e : entries) + { + store_stmt(version, + device, + md5(e.key), + e.key, + e.op_name, + to_json_string(e.problem), + to_json_string(e.solution), + to_msgpack(migraphx::to_value(e.code))); + } + db.execute(commit_sql); + } + catch(...) + { + // A transaction left open would hold the write lock against every other process, so it + // is rolled back and the entries are lost instead. The caller reports the original error. + try + { + db.execute(rollback_sql); + } + catch(const std::exception& ex) + { + log::warn() << "Failed to roll back binary cache entries: " << ex.what(); + } + // A database that refused one write, often after waiting out the busy timeout, would + // most likely refuse the next one as well, so later stores are skipped and the cache + // carries on read-only. Lookups keep working. + store_stmt = {}; + log::warn() << "Binary cache is read-only from now on"; + throw; + } +} + +} // namespace gpu +} // namespace MIGRAPHX_INLINE_NS +} // namespace migraphx diff --git a/test/gpu/binary_cache.cpp b/test/gpu/binary_cache.cpp index 5a9332d33c2..a380bf26647 100644 --- a/test/gpu/binary_cache.cpp +++ b/test/gpu/binary_cache.cpp @@ -30,11 +30,18 @@ #include #include #include +#include #include +#include +#include +#include #include #include #include #include +#include +#include +#include #include #include #include @@ -45,6 +52,10 @@ #include #include #include +#include +#include +#include +#include static migraphx::program pointwise_program() { @@ -60,11 +71,11 @@ static migraphx::program pointwise_program() return p; } -static migraphx::compile_options cache_options(const migraphx::fs::path& dir, bool verify = false) +static migraphx::compile_options cache_options(const std::string& path, bool verify = false) { migraphx::compile_options options; - migraphx::set_backend_options( - options, {{"binary_cache", dir.string()}, {"binary_cache_verify", verify}}); + migraphx::set_backend_options(options, + {{"binary_cache", path}, {"binary_cache_verify", verify}}); return options; } @@ -84,11 +95,119 @@ static migraphx::gpu::binary_cache::entry make_entry(const std::string& key) migraphx::gpu::binary_cache::entry e; e.key = key; e.op_name = "pointwise"; + e.problem = migraphx::value{{"shape", "float_type{4, 8}"}}; e.solution = migraphx::value{{"algo", "block"}}; e.code = make_code(); return e; } +// The storage backend is chosen by the extension of the cache path. +static std::string dir_path(const migraphx::tmp_dir& td) { return td.path.string(); } +static std::string db_path(const migraphx::tmp_dir& td) { return (td.path / "cache.db").string(); } + +/// The entry files a directory-backed cache has written. +static std::vector entry_files(const migraphx::fs::path& dir) +{ + std::vector result; + migraphx::transform_if( + migraphx::fs::recursive_directory_iterator{dir}, + migraphx::fs::recursive_directory_iterator{}, + std::back_inserter(result), + [](const auto& file) { return file.path().extension() == ".mxr"; }, + [](const auto& file) { return file.path(); }); + return result; +} + +/// Rows in one table of a cache database. The count is aliased because sqlite::execute keys its +/// rows by column name, and an unaliased count(*) would be keyed by the text of the expression. +static std::size_t row_count(const std::string& path, const std::string& table) +{ + auto rows = migraphx::sqlite::read(path).execute("SELECT count(*) AS n FROM " + table + ";"); + if(rows.empty()) + return 0; + return std::stoul(rows.front().at("n")); +} + +/// The full key of every stored entry, keyed by the key hash that addresses it. +using stored_entries = std::map; + +/// Every entry a cache directory holds. Each file is named by its key hash and holds the msgpack +/// of the whole entry. +static stored_entries dir_entries(const migraphx::fs::path& dir) +{ + stored_entries result; + auto files = entry_files(dir); + std::transform( + files.begin(), files.end(), std::inserter(result, result.end()), [](const auto& f) { + auto v = migraphx::from_msgpack(migraphx::read_buffer(f)); + return std::make_pair(f.stem().string(), v.at("key").get_string()); + }); + return result; +} + +/// Every entry a cache database holds. +static stored_entries db_entries(const std::string& path) +{ + stored_entries result; + auto select = migraphx::sqlite::read(path).prepare("SELECT key_hash, key FROM cache_v1;"); + auto rows = select(); + std::transform(rows.begin(), rows.end(), std::inserter(result, result.end()), [](auto row) { + return std::make_pair(row.at("key_hash").get_string(), row.at("key").get_string()); + }); + return result; +} + +/// Whether two entries hold the same thing. +static bool same_entry(const migraphx::gpu::binary_cache::entry& x, + const migraphx::gpu::binary_cache::entry& y) +{ + return x.key == y.key and x.op_name == y.op_name and x.problem == y.problem and + x.solution == y.solution and x.code.fill_map == y.code.fill_map and + *x.code.fragment.get_main_module() == *y.code.fragment.get_main_module(); +} + +// What a case that must hold for both backends needs to know about each, so the case can be +// written once as a template and registered for both. +struct directory_backend +{ + static std::string path(const migraphx::tmp_dir& td) { return dir_path(td); } + static std::size_t stored(const std::string& p) { return entry_files(p).size(); } + /// Overwrite every stored entry with bytes that do not decode. + static void damage(const std::string& p) + { + auto files = entry_files(p); + std::for_each(files.begin(), files.end(), [](const auto& file) { + migraphx::write_buffer(file, std::vector(8, 0)); + }); + } +}; + +struct database_backend +{ + static std::string path(const migraphx::tmp_dir& td) { return db_path(td); } + static std::size_t stored(const std::string& p) { return row_count(p, "cache_v1"); } + /// Overwrite every stored entry's code with bytes that do not decode. + static void damage(const std::string& p) + { + // 0xc1 is never used in msgpack, so the code cannot be decoded. + migraphx::sqlite::write(p).execute("UPDATE cache_v1 SET code = X'c1c1c1c1';"); + } +}; + +/// One of each backend over fresh storage in td: a directory at td/files and a database at +/// db_path(td). Driven directly, bypassing binary_cache, so callers pass their own version and +/// device strings. +static std::vector both_backends(const migraphx::tmp_dir& td) +{ + std::vector result; + result.emplace_back(migraphx::gpu::file_binary_cache{td.path / "files"}); + auto db = migraphx::gpu::sqlite_binary_cache::open(db_path(td)); + EXPECT(db.has_value()); + if(db.has_value()) + result.emplace_back(std::move(*db)); + return result; +} + TEST_CASE(lookup_records_a_miss) { migraphx::gpu::context ctx; @@ -106,7 +225,7 @@ TEST_CASE(memory_lookup_records_reuse) migraphx::gpu::context ctx; migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{.path = ""}}; - cache.insert(ctx, make_entry("a-key")); + cache.insert(ctx, {make_entry("a-key")}); EXPECT(cache.get_stats().compiled == 1); auto found = cache.get(ctx, "a-key"); @@ -115,15 +234,20 @@ TEST_CASE(memory_lookup_records_reuse) EXPECT(cache.get_stats().misses == 0); } -// A second cache shares nothing in memory, so anything it finds came off disk. -TEST_CASE(disk_lookup_records_a_hit) +// The cases below are written once against a Backend and registered for each. The directory +// registrations use real temporary paths so that on Windows they exercise the full depth of an +// entry path against MAX_PATH. + +// A second cache shares nothing in memory, so anything it finds came out of storage. +template +static void disk_lookup_records_a_hit() { migraphx::tmp_dir td{"binary-cache"}; migraphx::gpu::context ctx; - migraphx::gpu::binary_cache_settings settings{td.path.string(), false}; + migraphx::gpu::binary_cache_settings settings{Backend::path(td), false}; migraphx::gpu::binary_cache writer{settings}; - writer.insert(ctx, make_entry("shared-key")); + writer.insert(ctx, {make_entry("shared-key")}); migraphx::gpu::binary_cache reader{settings}; @@ -133,32 +257,30 @@ TEST_CASE(disk_lookup_records_a_hit) EXPECT(reader.get_stats().misses == 0); EXPECT(*found->fragment.get_main_module() == *make_code().fragment.get_main_module()); } +TEST_CASE_REGISTER(disk_lookup_records_a_hit); +TEST_CASE_REGISTER(disk_lookup_records_a_hit); // A damaged entry must cost a recompile and nothing more. -TEST_CASE(corrupt_entry_is_ignored) +template +static void corrupt_entry_is_ignored() { migraphx::tmp_dir td{"binary-cache"}; migraphx::gpu::context ctx; - migraphx::gpu::binary_cache_settings settings{td.path.string(), false}; + auto path = Backend::path(td); + migraphx::gpu::binary_cache_settings settings{path, false}; migraphx::gpu::binary_cache writer{settings}; - writer.insert(ctx, make_entry("damaged")); - - std::size_t truncated = 0; - for(const auto& file : migraphx::fs::recursive_directory_iterator(td.path)) - { - if(file.path().extension() != ".mxr") - continue; - migraphx::write_buffer(file.path(), std::vector(8, 0)); - truncated++; - } - EXPECT(truncated > 0); + writer.insert(ctx, {make_entry("damaged")}); + EXPECT(Backend::stored(path) == 1); + Backend::damage(path); migraphx::gpu::binary_cache reader{settings}; EXPECT(not reader.get(ctx, "damaged").has_value()); EXPECT(reader.get_stats().misses == 1); } +TEST_CASE_REGISTER(corrupt_entry_is_ignored); +TEST_CASE_REGISTER(corrupt_entry_is_ignored); // Without a directory nothing reaches disk, though results are still shared in memory. TEST_CASE(no_directory_writes_nothing) @@ -167,7 +289,7 @@ TEST_CASE(no_directory_writes_nothing) migraphx::gpu::context ctx; migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{.path = ""}}; - cache.insert(ctx, make_entry("in-memory-only")); + cache.insert(ctx, {make_entry("in-memory-only")}); EXPECT(cache.get(ctx, "in-memory-only").has_value()); EXPECT(cache.get_stats().reused == 1); EXPECT(migraphx::fs::is_empty(td.path)); @@ -215,12 +337,14 @@ TEST_CASE(duplicate_kernels_compile_once_without_a_directory) EXPECT(cache->get_stats().reused == 1); } -// Compiling twice against the same directory has to leave entries behind and keep producing the +// Compiling twice against the same cache has to leave entries behind and keep producing the // same numbers as the reference, whichever half of the run they came from. -TEST_CASE(compiling_twice_populates_the_cache_and_matches_reference) +template +static void compiling_twice_populates_the_cache_and_matches_reference() { migraphx::tmp_dir td{"binary-cache"}; - auto options = cache_options(td.path); + auto path = Backend::path(td); + auto options = cache_options(path); auto p_ref = pointwise_program(); p_ref.compile(migraphx::make_target("ref")); @@ -234,11 +358,7 @@ TEST_CASE(compiling_twice_populates_the_cache_and_matches_reference) auto warmup = pointwise_program(); warmup.compile(migraphx::make_target("gpu"), options); - auto entries = - std::count_if(migraphx::fs::recursive_directory_iterator{td.path}, - migraphx::fs::recursive_directory_iterator{}, - [](const auto& file) { return file.path().extension() == ".mxr"; }); - EXPECT(entries > 0); + EXPECT(Backend::stored(path) > 0); auto t = migraphx::make_target("gpu"); auto p = pointwise_program(); @@ -260,12 +380,16 @@ TEST_CASE(compiling_twice_populates_the_cache_and_matches_reference) gpu_result.to_vector())); } +TEST_CASE_REGISTER(compiling_twice_populates_the_cache_and_matches_reference); +TEST_CASE_REGISTER(compiling_twice_populates_the_cache_and_matches_reference); + // With verification on, every reused result is compiled again and compared, so a run that does // not throw is one where the keys really do capture what the compilers depend on. -TEST_CASE(verified_reuse_matches_fresh_compiles) +template +static void verified_reuse_matches_fresh_compiles() { migraphx::tmp_dir td{"binary-cache"}; - auto options = cache_options(td.path, /* verify */ true); + auto options = cache_options(Backend::path(td), /* verify */ true); auto warmup = pointwise_program(); warmup.compile(migraphx::make_target("gpu"), options); @@ -274,6 +398,450 @@ TEST_CASE(verified_reuse_matches_fresh_compiles) p.compile(migraphx::make_target("gpu"), options); } +TEST_CASE_REGISTER(verified_reuse_matches_fresh_compiles); +TEST_CASE_REGISTER(verified_reuse_matches_fresh_compiles); + +// The extension of the path picks the backend and nothing else does, so the only way to see the +// choice from outside is the artifact it leaves: a database file, or a directory tree. +TEST_CASE(extension_selects_the_backend) +{ + migraphx::gpu::context ctx; + const auto& version_dir = migraphx::gpu::binary_cache::version_id(true); + + migraphx::tmp_dir dir_td{"binary-cache"}; + migraphx::gpu::binary_cache dir_cache{ + migraphx::gpu::binary_cache_settings{dir_path(dir_td), false}}; + dir_cache.insert(ctx, {make_entry("in-a-directory")}); + auto files = entry_files(dir_td.path); + EXPECT(files.size() == 1); + EXPECT(migraphx::fs::is_directory(dir_td.path / version_dir)); + EXPECT(std::all_of(files.begin(), files.end(), [&](const auto& f) { + return f.parent_path().parent_path() == dir_td.path / version_dir; + })); + + for(const char* name : {"cache.db", "cache.sqlite"}) + { + migraphx::tmp_dir db_td{"binary-cache"}; + auto path = (db_td.path / name).string(); + migraphx::gpu::binary_cache db_cache{migraphx::gpu::binary_cache_settings{path, false}}; + db_cache.insert(ctx, {make_entry("in-a-database")}); + + EXPECT(migraphx::fs::is_regular_file(path)); + EXPECT(row_count(path, "cache_v1") == 1); + EXPECT(entry_files(db_td.path).empty()); + EXPECT(not migraphx::fs::exists(db_td.path / version_dir)); + } +} + +// A database that cannot be opened leaves a memory-only cache rather than an error. The parent +// component here is a regular file, so neither creating the directory nor opening the database +// can succeed. +TEST_CASE(unusable_database_degrades_to_memory) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto blocker = td.path / "not_a_dir"; + migraphx::write_buffer(blocker, std::vector(4, 0)); + migraphx::gpu::binary_cache_settings settings{(blocker / "cache.db").string(), false}; + + migraphx::gpu::binary_cache cache{settings}; + cache.insert(ctx, {make_entry("nowhere")}); + EXPECT(cache.get(ctx, "nowhere").has_value()); + EXPECT(cache.get_stats().reused == 1); + + // Nothing was persisted, so a second cache finds nothing. + migraphx::gpu::binary_cache reader{settings}; + EXPECT(not reader.get(ctx, "nowhere").has_value()); + EXPECT(reader.get_stats().misses == 1); +} + +// A cache path that already holds something other than a cache database is left alone: the +// cache runs from memory, and the file is not overwritten. +TEST_CASE(not_a_database_degrades_to_memory) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = db_path(td); + const std::vector garbage(64, 'x'); + migraphx::write_buffer(path, garbage); + + migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{path, false}}; + cache.insert(ctx, {make_entry("in-memory")}); + EXPECT(cache.get(ctx, "in-memory").has_value()); + EXPECT(cache.get_stats().reused == 1); + EXPECT((migraphx::read_buffer(path) == garbage)); +} + +// A database whose cache table has a different shape, as a future or foreign version might +// leave, cannot be stored into or looked up in, so it is skipped rather than half used. +TEST_CASE(incompatible_schema_degrades_to_memory) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = db_path(td); + migraphx::sqlite::write(path).execute("CREATE TABLE cache_v1 (unrelated INTEGER);"); + + EXPECT(not migraphx::gpu::sqlite_binary_cache::open(path).has_value()); + + migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{path, false}}; + cache.insert(ctx, {make_entry("in-memory")}); + EXPECT(cache.get(ctx, "in-memory").has_value()); + EXPECT(row_count(path, "cache_v1") == 0); +} + +// Both backends address an entry by the same key hash. +TEST_CASE(backends_store_the_same_entry) +{ + migraphx::gpu::context ctx; + auto e = make_entry("interchange"); + + migraphx::tmp_dir dir_td{"binary-cache"}; + migraphx::gpu::binary_cache dir_cache{ + migraphx::gpu::binary_cache_settings{dir_path(dir_td), false}}; + dir_cache.insert(ctx, {e}); + auto from_dir = dir_entries(dir_td.path); + EXPECT(from_dir.size() == 1); + EXPECT(from_dir.begin()->first == migraphx::md5(e.key)); + EXPECT(from_dir.begin()->second == e.key); + + migraphx::tmp_dir db_td{"binary-cache"}; + auto path = db_path(db_td); + migraphx::gpu::binary_cache db_cache{migraphx::gpu::binary_cache_settings{path, false}}; + db_cache.insert(ctx, {e}); + EXPECT((db_entries(path) == from_dir)); +} + +// A whole compile against each backend has to leave the same keys behind, under the same key +// hashes. That makes the choice of backend purely a storage decision. +TEST_CASE(backends_hold_the_same_entries_after_a_compile) +{ + migraphx::tmp_dir dir_td{"binary-cache"}; + migraphx::tmp_dir db_td{"binary-cache"}; + auto path = db_path(db_td); + + auto p_dir = pointwise_program(); + p_dir.compile(migraphx::make_target("gpu"), cache_options(dir_path(dir_td))); + auto p_db = pointwise_program(); + p_db.compile(migraphx::make_target("gpu"), cache_options(path)); + + auto from_dir = dir_entries(dir_td.path); + auto from_db = db_entries(path); + EXPECT(not from_dir.empty()); + EXPECT((from_dir == from_db)); +} + +// An entry loaded from one backend and stored into the other is a hit there and decodes to the +// same code, so an existing cache can be converted rather than rebuilt. The only translation is +// the version, which a directory names with the short id and a database records in full. +TEST_CASE(entries_move_between_backends) +{ + migraphx::gpu::context ctx; + const auto& short_version = migraphx::gpu::binary_cache::version_id(true); + const auto& long_version = migraphx::gpu::binary_cache::version_id(false); + auto e = make_entry("moving"); + auto expected = make_code(); + + // Directory to database. + { + migraphx::tmp_dir dir_td{"binary-cache"}; + migraphx::tmp_dir db_td{"binary-cache"}; + migraphx::gpu::binary_cache writer{ + migraphx::gpu::binary_cache_settings{dir_path(dir_td), false}}; + writer.insert(ctx, {e}); + auto files = entry_files(dir_td.path); + EXPECT(files.size() == 1); + + auto device = files.front().parent_path().filename().string(); + migraphx::gpu::file_binary_cache dir{dir_td.path}; + auto loaded = dir.load(short_version, device, e.key); + EXPECT(loaded.has_value()); + auto db = migraphx::gpu::sqlite_binary_cache::open(db_path(db_td)); + EXPECT(db.has_value()); + db->store(long_version, device, {*loaded}); + + migraphx::gpu::binary_cache reader{ + migraphx::gpu::binary_cache_settings{db_path(db_td), false}}; + auto found = reader.get(ctx, e.key); + EXPECT(found.has_value()); + EXPECT(reader.get_stats().hits == 1); + EXPECT(*found->fragment.get_main_module() == *expected.fragment.get_main_module()); + } + + // Database to directory. + { + migraphx::tmp_dir db_td{"binary-cache"}; + migraphx::tmp_dir dir_td{"binary-cache"}; + migraphx::gpu::binary_cache writer{ + migraphx::gpu::binary_cache_settings{db_path(db_td), false}}; + writer.insert(ctx, {e}); + + auto rows = + migraphx::sqlite::read(db_path(db_td)).execute("SELECT version, device FROM cache_v1;"); + EXPECT(rows.size() == 1); + EXPECT(rows.front().at("version") == long_version); + const auto& device = rows.front().at("device"); + auto db = migraphx::gpu::sqlite_binary_cache::open(db_path(db_td)); + EXPECT(db.has_value()); + auto loaded = db->load(long_version, device, e.key); + EXPECT(loaded.has_value()); + migraphx::gpu::file_binary_cache dir{dir_td.path}; + dir.store(short_version, device, {*loaded}); + + migraphx::gpu::binary_cache reader{ + migraphx::gpu::binary_cache_settings{dir_path(dir_td), false}}; + auto found = reader.get(ctx, e.key); + EXPECT(found.has_value()); + EXPECT(reader.get_stats().hits == 1); + EXPECT(*found->fragment.get_main_module() == *expected.fragment.get_main_module()); + } +} + +// Two connections over one database, as two processes compiling against a shared cache would +// have. +TEST_CASE(two_connections_share_a_database) +{ + migraphx::tmp_dir td{"binary-cache"}; + auto path = db_path(td); + + auto a = migraphx::gpu::sqlite_binary_cache::open(path); + auto b = migraphx::gpu::sqlite_binary_cache::open(path); + EXPECT(a.has_value()); + EXPECT(b.has_value()); + + a->store("v", "dev", {make_entry("k1")}); + auto from_b = b->load("v", "dev", "k1"); + EXPECT(from_b.has_value()); + EXPECT(from_b->key == "k1"); + + b->store("v", "dev", {make_entry("k2")}); + auto from_a = a->load("v", "dev", "k2"); + EXPECT(from_a.has_value()); + EXPECT(from_a->key == "k2"); + + EXPECT(not a->load("v", "dev", "absent").has_value()); +} + +// A database has no directory to name the build that wrote a row, so each row records the +// version id, and in full, since unlike the directory backend's directory names it has no path +// length to protect. +TEST_CASE(sqlite_records_the_full_version_id) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = db_path(td); + migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{path, false}}; + + cache.insert(ctx, {make_entry("one")}); + cache.insert(ctx, {make_entry("two")}); + EXPECT(row_count(path, "cache_v1") == 2); + + auto rows = migraphx::sqlite::read(path).execute("SELECT DISTINCT version FROM cache_v1;"); + EXPECT(rows.size() == 1); + EXPECT(rows.front().at("version") == migraphx::gpu::binary_cache::version_id(false)); +} + +// Each field of an entry has its own column, so a cache can be inspected with SQL. +TEST_CASE(sqlite_records_what_each_entry_was_compiled_for) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = db_path(td); + auto e = make_entry("described"); + migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{path, false}}; + cache.insert(ctx, {e}); + + auto rows = migraphx::sqlite::read(path).execute( + "SELECT key_hash, key, op_name, problem, solution FROM cache_v1;"); + EXPECT(rows.size() == 1); + const auto& row = rows.front(); + EXPECT(row.at("key_hash") == migraphx::md5(e.key)); + EXPECT(row.at("key") == e.key); + EXPECT(row.at("op_name") == e.op_name); + EXPECT(migraphx::from_json_string(row.at("problem")) == e.problem); + EXPECT(migraphx::from_json_string(row.at("solution")) == e.solution); +} + +// A database that cannot be written to, such as a shared cache installed read-only, still +// serves the entries already in it, and storing into it is quietly skipped. +TEST_CASE(sqlite_read_only_database_still_serves_hits) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = db_path(td); + migraphx::gpu::binary_cache_settings settings{path, false}; + { + migraphx::gpu::binary_cache writer{settings}; + writer.insert(ctx, {make_entry("existing")}); + } + + const auto writable = migraphx::fs::perms::owner_write | migraphx::fs::perms::group_write | + migraphx::fs::perms::others_write; + migraphx::fs::permissions(path, writable, migraphx::fs::perm_options::remove); + // Permissions do not stop root, so what can be checked about stores depends on whether the + // write protection actually took. + const bool protected_file = migraphx::sqlite::write(path).read_only(); + + migraphx::gpu::binary_cache reader{settings}; + EXPECT(reader.get(ctx, "existing").has_value()); + EXPECT(reader.get_stats().hits == 1); + + reader.insert(ctx, {make_entry("new")}); + EXPECT(reader.get(ctx, "new").has_value()); + if(protected_file) + { + EXPECT(row_count(path, "cache_v1") == 1); + } + + // Restored so the temporary directory can be removed, which Windows refuses otherwise. + migraphx::fs::permissions( + path, migraphx::fs::perms::owner_write, migraphx::fs::perm_options::add); +} + +// version and device separate entries this build may use from entries it may not, so an entry +// stored under one must not be served under another, whichever backend holds it. +TEST_CASE(backends_scope_entries_by_version_and_device) +{ + migraphx::tmp_dir td{"binary-cache"}; + for(auto& backend : both_backends(td)) + { + backend.store("v1", "dev1", {make_entry("k")}); + + EXPECT(backend.load("v1", "dev1", "k").has_value()); + EXPECT(not backend.load("v2", "dev1", "k").has_value()); + EXPECT(not backend.load("v1", "dev2", "k").has_value()); + } +} + +// Storing a key twice replaces the entry rather than accumulating or failing. Two processes +// compiling the same kernel is benign for exactly this reason. +TEST_CASE(backends_store_overwrites_in_place) +{ + migraphx::tmp_dir td{"binary-cache"}; + auto original = make_entry("k"); + auto replacement = make_entry("k"); + replacement.solution = migraphx::value{{"algo", "replaced"}}; + for(auto& backend : both_backends(td)) + { + backend.store("v", "dev", {original}); + backend.store("v", "dev", {replacement}); + + auto got = backend.load("v", "dev", "k"); + EXPECT(got.has_value()); + EXPECT(got->solution == replacement.solution); + } + EXPECT(row_count(db_path(td), "cache_v1") == 1); + EXPECT(entry_files(td.path / "files").size() == 1); +} + +// Publishing an entry goes through a temporary beside it, and nothing of that may be left once +// the entry is in place, including when an existing entry is replaced. +TEST_CASE(file_store_leaves_only_entries_behind) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + migraphx::gpu::binary_cache_settings settings{dir_path(td), false}; + + migraphx::gpu::binary_cache first{settings}; + first.insert(ctx, {make_entry("one")}); + first.insert(ctx, {make_entry("two")}); + // A second cache stores the same key again, over the file the first one published. + migraphx::gpu::binary_cache second{settings}; + second.insert(ctx, {make_entry("one")}); + + std::vector items{ + migraphx::fs::recursive_directory_iterator{td.path}, + migraphx::fs::recursive_directory_iterator{}}; + auto dirs = std::count_if( + items.begin(), items.end(), [](const auto& item) { return item.is_directory(); }); + // Just the version directory and the device directory inside it, and the two entries. + EXPECT(dirs == 2); + EXPECT(entry_files(td.path).size() == 2); + EXPECT(items.size() == 4); +} + +// Storage is opened by the first lookup or insert, not by constructing the cache, since every +// context makes one whether or not it ever compiles anything. +TEST_CASE(storage_is_opened_on_first_use) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = db_path(td); + migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{path, false}}; + EXPECT(not migraphx::fs::exists(path)); + + EXPECT(not cache.get(ctx, "absent").has_value()); + EXPECT(migraphx::fs::exists(path)); +} + +// Entries inserted together are all there afterwards. The database commits them in one +// transaction; the directory backend writes each file as it comes. +template +static void inserts_of_many_entries_are_all_stored() +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = Backend::path(td); + migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{path, false}}; + cache.insert(ctx, {make_entry("first"), make_entry("second")}); + EXPECT(Backend::stored(path) == 2); + + migraphx::gpu::binary_cache reader{migraphx::gpu::binary_cache_settings{path, false}}; + EXPECT(reader.get(ctx, "first").has_value()); + EXPECT(reader.get(ctx, "second").has_value()); + EXPECT(reader.get_stats().hits == 2); +} + +TEST_CASE_REGISTER(inserts_of_many_entries_are_all_stored); +TEST_CASE_REGISTER(inserts_of_many_entries_are_all_stored); + +// A store holds the database's write lock for its transaction, so another connection must be +// able to write again as soon as it returns. +TEST_CASE(sqlite_store_releases_the_database) +{ + migraphx::tmp_dir td{"binary-cache"}; + migraphx::gpu::context ctx; + auto path = db_path(td); + migraphx::gpu::binary_cache cache{migraphx::gpu::binary_cache_settings{path, false}}; + cache.insert(ctx, {make_entry("first"), make_entry("second")}); + + auto other = migraphx::gpu::sqlite_binary_cache::open(path); + EXPECT(other.has_value()); + other->store("v", "dev", {make_entry("k")}); + EXPECT(row_count(path, "cache_v1") == 3); +} + +// Rows are addressed by a hash of the key, so a row whose stored key differs from the one asked +// for, as a hash collision would leave, is a miss rather than a wrong kernel. +TEST_CASE(sqlite_mismatched_key_is_a_miss) +{ + migraphx::tmp_dir td{"binary-cache"}; + auto path = db_path(td); + auto db = migraphx::gpu::sqlite_binary_cache::open(path); + EXPECT(db.has_value()); + db->store("v", "dev", {make_entry("k")}); + EXPECT(db->load("v", "dev", "k").has_value()); + + migraphx::sqlite::write(path).execute("UPDATE cache_v1 SET key = 'another';"); + EXPECT(not db->load("v", "dev", "k").has_value()); +} + +// Both backends go through the same type-erased wrapper and give back the entry they were +// given, field for field. +TEST_CASE(backends_round_trip_through_the_wrapper) +{ + migraphx::tmp_dir td{"binary-cache"}; + auto e = make_entry("round-trip"); + + for(auto& backend : both_backends(td)) + { + EXPECT(not backend.load("v", "dev", e.key).has_value()); + backend.store("v", "dev", {e}); + auto got = backend.load("v", "dev", e.key); + EXPECT(got.has_value()); + EXPECT(same_entry(*got, e)); + } +} + TEST_CASE(entry_round_trip) { auto e = make_entry("some-key"); @@ -282,11 +850,7 @@ TEST_CASE(entry_round_trip) migraphx::gpu::binary_cache::entry loaded; migraphx::from_value(migraphx::from_msgpack(buffer), loaded); - EXPECT(loaded.key == e.key); - EXPECT(loaded.op_name == e.op_name); - EXPECT(loaded.solution == e.solution); - EXPECT(loaded.code.fill_map == e.code.fill_map); - EXPECT(*loaded.code.fragment.get_main_module() == *e.code.fragment.get_main_module()); + EXPECT(same_entry(loaded, e)); } // The key has to cover everything handed to the compiler, not just the source text. Two diff --git a/test/sqlite.cpp b/test/sqlite.cpp index f29099f052d..02910f109af 100644 --- a/test/sqlite.cpp +++ b/test/sqlite.cpp @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2015-2025 Advanced Micro Devices, Inc. All rights reserved. + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -21,9 +21,20 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ +#include #include #include #include +#include +#include +#include +#include + +/// Every row a call produced, so a test can count and inspect them. +static std::vector collect(const migraphx::sqlite_stmt::rows& r) +{ + return {r.begin(), r.end()}; +} TEST_CASE(read_write) { @@ -55,4 +66,146 @@ TEST_CASE(read_write) } } +TEST_CASE(prepared_blob_round_trip) +{ + // Bytes that raw SQL text cannot carry: an embedded NUL and a single quote. This is the + // reason binaries need parameter binding rather than string interpolation. + const std::vector blob{'\0', 'a', '\'', '\0', static_cast(0xff), 'z'}; + + migraphx::tmp_dir td{}; + auto db_path = td.path / "blob.db"; + { + auto db = migraphx::sqlite::write(db_path); + db.execute(R"__migraphx__( + CREATE TABLE IF NOT EXISTS blob_db ( + name TEXT PRIMARY KEY, + size INTEGER NOT NULL, + data BLOB NOT NULL + ); + )__migraphx__"); + + // One statement, two inserts: calling it again rebinds, which is what backends rely on. + // An insert produces no rows, and runs whether or not they are iterated. + auto insert = db.prepare("INSERT INTO blob_db (name, size, data) VALUES (?, ?, ?);"); + EXPECT(insert.valid()); + EXPECT(collect(insert("k1", static_cast(blob.size()), blob)).empty()); + insert("empty", std::int64_t{0}, std::vector{}); + } + { + auto db = migraphx::sqlite::read(db_path); + auto select = db.prepare("SELECT name, size, data FROM blob_db WHERE name = ?;"); + + auto found = collect(select("k1")); + EXPECT(found.size() == 1); + EXPECT(found.front().at("name").get_string() == "k1"); + EXPECT(found.front().at("size").to() == blob.size()); + EXPECT(found.front().at("data").get_binary() == migraphx::value::binary{blob}); + + // An empty blob must come back as an empty blob, not as NULL. + auto empty = collect(select("empty")); + EXPECT(empty.size() == 1); + EXPECT(empty.front().at("data").is_binary()); + EXPECT(empty.front().at("data").get_binary().empty()); + + EXPECT(collect(select("missing")).empty()); + } +} + +// A select abandoned after its first row must not keep holding the database. Until the +// statement is reset it holds a read lock, and a writer on another connection would wait out +// its busy timeout and then fail. +TEST_CASE(abandoned_rows_release_the_database) +{ + migraphx::tmp_dir td{}; + auto db_path = td.path / "lock.db"; + auto writer = migraphx::sqlite::write(db_path); + writer.execute("CREATE TABLE t (id INTEGER PRIMARY KEY);" + "INSERT INTO t (id) VALUES (1), (2);"); + + auto reader = migraphx::sqlite::read(db_path); + auto select = reader.prepare("SELECT id FROM t;"); + { + // Read one of the two rows and stop. + auto rows = select(); + EXPECT(rows.begin() != rows.end()); + } + + auto insert = writer.prepare("INSERT INTO t (id) VALUES (?);"); + insert(std::int64_t{3}); + EXPECT(writer.execute("SELECT id FROM t;").size() == 3); +} + +// Each column comes back as the value type matching what sqlite stored, keyed by its name. +TEST_CASE(rows_convert_column_types) +{ + migraphx::tmp_dir td{}; + auto db = migraphx::sqlite::write(td.path / "types.db"); + auto select = + db.prepare("SELECT 42 AS i, 2.5 AS f, 'text' AS t, x'00ff' AS b, NULL AS n, ?1 AS p;"); + + auto rows = collect(select(std::int64_t{-7})); + EXPECT(rows.size() == 1); + const auto& row = rows.front(); + EXPECT(row.size() == 6); + EXPECT(row.at("i").is_int64()); + EXPECT(row.at("i").get_int64() == 42); + EXPECT(row.at("f").is_float()); + EXPECT(migraphx::float_equal(row.at("f").get_float(), 2.5)); + EXPECT(row.at("t").get_string() == "text"); + EXPECT(row.at("b").get_binary() == migraphx::value::binary{std::vector{0, 255}}); + EXPECT(row.at("n").is_null()); + EXPECT(row.at("p").get_int64() == -7); +} + +// A statement returning many rows yields each in turn, and calling it again starts over. +TEST_CASE(rows_iterate_in_order_and_restart) +{ + migraphx::tmp_dir td{}; + auto db = migraphx::sqlite::write(td.path / "many.db"); + db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY);" + "INSERT INTO t (id) VALUES (1), (2), (3);"); + auto select = db.prepare("SELECT id FROM t WHERE id >= ?1 ORDER BY id;"); + + auto ids = [&](std::int64_t from) { + std::vector result; + auto rows = select(from); + std::transform(rows.begin(), rows.end(), std::back_inserter(result), [](const auto& row) { + return row.at("id").get_int64(); + }); + return result; + }; + EXPECT((ids(1) == std::vector{1, 2, 3})); + EXPECT((ids(2) == std::vector{2, 3})); + EXPECT(ids(4).empty()); +} + +TEST_CASE(read_only_matches_how_it_was_opened) +{ + migraphx::tmp_dir td{}; + auto path = td.path / "mode.db"; + migraphx::sqlite::write(path).execute("CREATE TABLE t (id INTEGER PRIMARY KEY);"); + EXPECT(not migraphx::sqlite::write(path).read_only()); + EXPECT(migraphx::sqlite::read(path).read_only()); +} + +TEST_CASE(unprepared_statement_throws) +{ + migraphx::sqlite_stmt stmt; + EXPECT(not stmt.valid()); + EXPECT(test::throws([&] { stmt(); })); +} + +TEST_CASE(try_write_unusable_path) +{ + migraphx::tmp_dir td{}; + // A directory component that is really a file, so the database can never be created. + auto blocker = td.path / "not_a_dir"; + { + auto db = migraphx::sqlite::write(blocker); + db.execute("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY ASC);"); + } + EXPECT(not migraphx::sqlite::try_write(blocker / "nested.db").has_value()); + EXPECT(migraphx::sqlite::try_write(td.path / "ok.db").has_value()); +} + int main(int argc, const char* argv[]) { test::run(argc, argv); } diff --git a/tools/include/gpu/binary_cache_backend.hpp b/tools/include/gpu/binary_cache_backend.hpp new file mode 100644 index 00000000000..c5d0f93bf20 --- /dev/null +++ b/tools/include/gpu/binary_cache_backend.hpp @@ -0,0 +1,133 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015-2026 Advanced Micro Devices, Inc. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +// +// te.py DSL for migraphx::gpu::binary_cache_backend. +// +// The generated header lives at +// src/targets/gpu/include/migraphx/gpu/binary_cache_backend.hpp; regenerate it +// with `cd tools && python generate.py` (generate_all routes include/gpu/ inputs +// into the gpu target tree). Do not edit the generated header by hand. +// +// Any type T satisfies the binary_cache_backend concept if it provides the +// member functions listed below. The wrapper holds T by shared_ptr and forwards +// each call through a virtual dispatch, matching problem_cache_backend. +// +// Notes: +// * binary_cache_entry is defined in ; +// the include below pulls in its full definition. +// * Backends must be copyable: the wrapper shares T and clones it on a +// non-const call while the handle is shared. sqlite_binary_cache shares its +// connection across copies. +// +#ifndef MIGRAPHX_GUARD_GPU_BINARY_CACHE_BACKEND_HPP +#define MIGRAPHX_GUARD_GPU_BINARY_CACHE_BACKEND_HPP + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace migraphx { +inline namespace MIGRAPHX_INLINE_NS { +namespace gpu { + +#ifdef DOXYGEN + +/// Type-erased interface for binary-cache storage backends. +/// +/// A backend persists binary_cache_entry values to some medium (a directory of +/// files or a SQLite database), and decides for itself how to serialize them. +/// Entries are addressed by their key, scoped by two strings the caller has +/// already computed: +/// +/// * `version` -- binary_cache::version_id(), short for the directory +/// backend and full for the database, identifying the toolchain and the +/// embedded kernel sources that produced the entry. Never empty; the +/// caller skips persistence entirely when it is. +/// * `device` -- the GPU the entry was compiled for. +/// +/// A backend must keep entries with different scopes distinct rather than +/// overwriting across them. It may address entries by a hash of the key, for +/// instance to keep file names short, but must then check the full key when +/// loading so that a collision is a miss rather than a wrong kernel. +struct binary_cache_backend +{ + /// Return the entry stored for this key, or nullopt for a miss. + /// + /// nullopt also covers every failure: a missing file, an unreadable + /// database, a damaged entry, a permissions problem. A cache that cannot be + /// read is not an error, it is a cache miss, and the caller recompiles. + /// + /// Must not throw. + optional + load(const std::string& version, const std::string& device, const std::string& key); + + /// Persist every entry in `entries` under its key. The entries arrive + /// together so a backend can commit them at once, such as in one database + /// transaction, rather than one at a time. + /// + /// Overwriting an existing entry is expected and safe: the content is + /// decided entirely by the key, so a writer that loses a race replaces the + /// entry with an equivalent one. + /// + /// May throw: the caller reports a failed store as a warning. It costs a + /// recompile next run, nothing more, and the caller still keeps the results + /// in memory. A backend that throws must not leave anything locked. + void store(const std::string& version, + const std::string& device, + const std::vector& entries); +}; + +#else + +<% + interface('binary_cache_backend', + virtual('load', + returns = 'optional', + version = 'const std::string&', + device = 'const std::string&', + key = 'const std::string&'), + virtual('store', + returns = 'void', + version = 'const std::string&', + device = 'const std::string&', + entries = 'const std::vector&')) +%> + +#endif + +} // namespace gpu +} // namespace MIGRAPHX_INLINE_NS +} // namespace migraphx + +#endif // MIGRAPHX_GUARD_GPU_BINARY_CACHE_BACKEND_HPP