Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/scale-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Scale Tests

on:
workflow_dispatch:
schedule:
- cron: "37 8 * * 0"

permissions:
contents: read

jobs:
scale:
name: Julia 1 - ubuntu-latest - x64 - scale
runs-on: ubuntu-latest
timeout-minutes: 75
env:
QUBOTOOLS_SCALE_TESTS: true
QUBOTOOLS_SCALE_MAX_N: 100000

steps:
- uses: actions/checkout@v6
- uses: julia-actions/setup-julia@v3
with:
version: "1"
arch: x64
- uses: julia-actions/cache@v3
- uses: julia-actions/julia-buildpkg@v1
- name: Run scale tests
run: julia --project=. -e 'using Pkg; Pkg.test(; test_args = ["scale-only"])'
1 change: 1 addition & 0 deletions docs/build.jl
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const DOCS_PAGES = [
"File Formats" => "manual/5-formats.md",
"Solutions" => "manual/6-solutions.md",
"Analysis" => "manual/7-analysis.md",
"Scalability" => "manual/8-scalability.md",
],
"Formats" => [
"BQPJSON" => "formats/bqpjson.md",
Expand Down
54 changes: 54 additions & 0 deletions docs/src/manual/8-scalability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Scalability

QUBOTools keeps the default test suite focused on small fixtures so that
`Pkg.test("QUBOTools")` remains suitable for pull requests and local
development. Large generated instances are covered by an opt-in scale-test tier.

Run the full scale tier from the repository root with:

```bash
QUBOTOOLS_SCALE_TESTS=true julia --project=. -e 'using Pkg; Pkg.test(; test_args = ["scale-only"])'
```

To run a smaller local smoke pass, cap the maximum generated dimension:

```bash
QUBOTOOLS_SCALE_TESTS=true QUBOTOOLS_SCALE_MAX_N=5000 julia --project=. -e 'using Pkg; Pkg.test(; test_args = ["scale-only"])'
```

The scheduled GitHub Actions scale job runs generated sparse cases at
`n = 1000`, `5000`, `20000`, and `100000` with average degree 4. These cases
exercise:

- sparse model construction from generated linear and quadratic dictionaries;
- dense Sherrington-Kirkpatrick and Wishart synthesis smoke cases at `n = 1000`;
- energy evaluation on random states;
- sparse, dictionary, and dense form agreement at `n = 1000`;
- sparse and dictionary form agreement at larger sizes;
- QUBin round-trips at every scale-test size;
- QUBO text round-trips at `n = 20000` and `n = 100000`;
- exact Float64 preservation for a portfolio-like numerical range from
`1e-3` through `2e10`;
- `SampleSet` construction and duplicate-state merging with large states.

## Practical Envelope

Sparse forms are the intended representation for large generated and archived
instances. A sparse quadratic form is backed by Julia's `SparseMatrixCSC`, which
stores one row index and one value per nonzero term plus one column pointer per
variable. With 64-bit indices and `Float64` coefficients, the dominant storage
is about 16 bytes per quadratic nonzero plus about 8 bytes per variable for
column pointers. Sparse linear terms add about 16 bytes per nonzero. Temporary
construction dictionaries and I/O buffers require additional memory, so peak
memory is higher than the final sparse form.

Dense forms use an `n x n` `Float64` matrix for quadratic terms. That is about
`8n^2` bytes before Julia array overhead, so dense forms are practical only for
small cross-checks. The scale tier limits dense correctness checks to
`n = 1000`.

The current scheduled envelope is sparse instances with 100000 variables and
about 200000 quadratic terms. Denser QOBLib-style archives with millions of
terms should be tested with a dedicated benchmark or data-validation workflow so
their time and memory budgets can be reviewed separately from regular package
tests.
25 changes: 21 additions & 4 deletions src/library/form/sparse.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ struct SparseLinearForm{T} <: AbstractLinearForm{T}
end

function SparseLinearForm{T}(n::Integer, lf::LF) where {T,S,LF<:AbstractLinearForm{S}}
data = spzeros(T, n)
indices = Int[]
values = T[]
sizehint!(indices, linear_size(lf))
sizehint!(values, linear_size(lf))

for (i, v) in linear_terms(lf)
data[i] = convert(T, v)
push!(indices, i)
push!(values, convert(T, v))
end

data = sparsevec(indices, values, n)
dropzeros!(data)

return SparseLinearForm{T}(data)
end

Expand Down Expand Up @@ -55,12 +62,22 @@ struct SparseQuadraticForm{T} <: AbstractQuadraticForm{T}
end

function SparseQuadraticForm{T}(n::Integer, qf::QF) where {T,S,QF<:AbstractQuadraticForm{S}}
data = zeros(T, n, n)
rows = Int[]
cols = Int[]
values = T[]
sizehint!(rows, quadratic_size(qf))
sizehint!(cols, quadratic_size(qf))
sizehint!(values, quadratic_size(qf))

for ((i, j), v) in quadratic_terms(qf)
data[i, j] = convert(T, v)
push!(rows, i)
push!(cols, j)
push!(values, convert(T, v))
end

data = sparse(rows, cols, values, n, n)
dropzeros!(data)

return SparseQuadraticForm{T}(data)
end

Expand Down
10 changes: 8 additions & 2 deletions src/library/format/qubo/parser.jl
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ function _parse_entry!(
L = data[:linear_terms]
Q = data[:quadratic_terms]

m = match(r"^([0-9]+)\s+([0-9]+)\s+([+-]?([0-9]*[.])?[0-9]+)$", line)
m = match(
r"^([0-9]+)\s+([0-9]+)\s+([+-]?(([0-9]+([.][0-9]*)?)|([.][0-9]+))([eE][+-]?[0-9]+)?)$",
line,
)

if isnothing(m)
return false
Expand All @@ -72,7 +75,10 @@ function _parse_entry!(data::Dict{Symbol,Any}, line::AbstractString, ::Format{:q
L = data[:linear_terms]
Q = data[:quadratic_terms]

m = match(r"^([0-9]+)\s+([0-9]+)\s+([+-]?([0-9]*[.])?[0-9]+)$", line)
m = match(
r"^([0-9]+)\s+([0-9]+)\s+([+-]?(([0-9]+([.][0-9]*)?)|([.][0-9]+))([eE][+-]?[0-9]+)?)$",
line,
)

if isnothing(m)
return false
Expand Down
24 changes: 22 additions & 2 deletions src/library/format/rudy/format.jl
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function parse_comment!(data, line::AbstractString, ::Format{:rudy})
end

# Constant term of objective = 66363.47
let m = match(r"^# Constant term of objective = ([+-]?([0-9]+([.][0-9]*)?|[.][0-9]+))$", line)
let m = match(r"^# Constant term of objective = ([+-]?(([0-9]+([.][0-9]*)?)|([.][0-9]+))([eE][+-]?[0-9]+)?)$", line)
if !isnothing(m)
data.offset = parse(Float64, m[1])

Expand All @@ -52,7 +52,7 @@ end
function parse_line!(data::RUDY_DATA{T}, line::AbstractString, fmt::Format{:rudy}) where {T}
startswith(line, "#") && return parse_comment!(data, line, fmt)

let m = match(r"^(\d+)\s+(\d+)\s+([+-]?([0-9]+([.][0-9]*)?|[.][0-9]+))$", line)
let m = match(r"^(\d+)\s+(\d+)\s+([+-]?(([0-9]+([.][0-9]*)?)|([.][0-9]+))([eE][+-]?[0-9]+)?)$", line)
if !isnothing(m)
# Note: rudy is 0-indexed!
i = parse(Int, m[1]) + 1
Expand All @@ -72,6 +72,26 @@ function parse_line!(data::RUDY_DATA{T}, line::AbstractString, fmt::Format{:rudy
end
end

function QUBOTools.write_model(io::IO, model::QUBOTools.AbstractModel, fmt::QUBOTools.Format{:rudy})
Φ = QUBOTools.form(model, :sparse; domain = fmt[:domain])
α = QUBOTools.scale(Φ)

println(io, "# Constant term of objective = $(α * QUBOTools.offset(Φ))")
println(io, "# Diagonal terms")

for (i, h) in QUBOTools.linear_terms(Φ)
println(io, "$(i - 1) $(i - 1) $(α * h)")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the new writer emits default Float64 strings, so values such as 1.0e-5 or 2.0e10 are valid output here but fail on immediate re-read because the Rudy parser only accepts fixed decimal literals. I reproduced this with a temporary spin model written through Format{:rudy}; read_model fails on 0 0 1.0e-5 with Syntax Error. Please either teach the Rudy parser the same scientific-notation numeric grammar accepted by QUBO, including constant comments, and add an exponent round-trip test, or format Rudy output with a representation the existing parser accepts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b34b8f5. The Rudy parser now accepts exponent-form numbers for coefficient lines and constant-term comments, and test/unit/library/formats.jl adds a write/read round-trip with exponent-form linear, quadratic, and offset values.

end

println(io, "# Off-Diagonal terms")

for ((i, j), J) in QUBOTools.quadratic_terms(Φ)
println(io, "$(i - 1) $(j - 1) $(α * J)")
end

return nothing
end

function QUBOTools.read_model(io::IO, fmt::QUBOTools.Format{:rudy})
data = RUDY_DATA{Float64}(; domain = QUBOTools.domain(fmt[:domain]))

Expand Down
26 changes: 23 additions & 3 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,26 @@ const QUBOModel = QUBOTools_MOI.QUBOModel
const NumberOfReads = QUBOTools_MOI.NumberOfReads

const __TEST_PATH__ = @__DIR__
const __SCALE_ONLY__ = "scale-only" in ARGS

function _scale_tests_requested()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate of _scale_tests_enabled() in test/scale/scale.jl (line 16); both parse QUBOTOOLS_SCALE_TESTS against the same truthy set. Consider defining it once and calling it from both entry points so the two cannot drift.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3088bf8. test/runtests.jl now always loads test/scale/scale.jl and uses the single _scale_tests_enabled() helper, so the main and standalone scale entry points cannot drift.

return lowercase(get(ENV, "QUBOTOOLS_SCALE_TESTS", "false")) in
Set(["1", "true", "yes", "on"])
end

# Include assets
include("assets/comparison.jl")
include("assets/foreign_tests.jl")

# Include test functions
include("unit/unit.jl")
include("integration/integration.jl")
if __SCALE_ONLY__ || _scale_tests_requested()
include("scale/scale.jl")
end

if !__SCALE_ONLY__
include("unit/unit.jl")
include("integration/integration.jl")
end

function test_main()
@testset "◈ ◈ ◈ QUBOTools.jl Test Suite ◈ ◈ ◈" verbose = true begin
Expand All @@ -46,4 +58,12 @@ function test_main()
return nothing
end

test_main() # Here we go!
if __SCALE_ONLY__
test_scale()
else
test_main() # Here we go!

if _scale_tests_requested()
test_scale()
end
end
11 changes: 11 additions & 0 deletions test/scale/runtests.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Test
using Random
using QUBOTools

include("scale.jl")

if _scale_tests_enabled()
test_scale()
else
@info "Set QUBOTOOLS_SCALE_TESTS=true to run the scale-test tier"
end
Loading
Loading