Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions docs/modules/ROOT/pages/how-to/databases-and-queries.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ db.query("sql", "SELECT FROM V WHERE @rid IN ?", [[rid_a, rid_b]])

The check counts the `?`, `$` and `:` characters of the SQL text, so the count is an upper bound: a `?` inside a string literal counts as a placeholder, and a call that binds is never refused. Fewer values than placeholders are not checked, and the engine binds null for the missing ones. `db.query` and `db.command` run the check; the async executor does not.

==== A query vector

A query vector for `vectorNeighbors` can be a numpy array or a list. The conversion before the statement runs is the cost to watch: a query takes about 0.3 to 0.75 ms, and a vector of 1536 numbers took 0.5 to 0.6 ms to convert when it was a list.

* A `float32` array crosses as one buffer on both builds: about 5 to 15 us. This is the fast path. `np.asarray(v, dtype=np.float32)` makes it from a list or from another array.
* A list that holds only Python floats (no `int`, `bool`, `None`, `str` or numpy scalar) also crosses as one buffer, and so does a `float64` array on the JVM build. The statement reads the same list of doubles it always read: no value changes, and an `int` in a list stays an `int`. A list shorter than 32 numbers (JVM build) or 64 numbers (native build) is not worth a buffer and goes number by number. On the native build the list crosses as a buffer only when `float32` holds every number exactly, which is true of a list from a float32 model (`array.tolist()`); a list of other doubles, `NaN` or an infinity is sent as text, because a buffer would round it.
* A `float64` array is a `float32` array to the engine on the native build and when it is the one positional parameter on the JVM build, so its numbers are rounded to `float32` (a vector index stores `float32`). As a named parameter on the JVM build it keeps its doubles.

=== Results

`db.query` returns a result set you can iterate. Two methods cover the usual needs:
Expand Down
15 changes: 14 additions & 1 deletion native/src/main/java/org/humemai/nativelib/Json.java
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ private Object object(final Map<String, Object> into) {
return out;
}

/** Python -> Java tagged values: n Decimal, i big int, m map with non-str keys, D date, T datetime (UTC wall clock), s set, b bytes (base64), F float[] (as a list), B float[] (from the side buffer), R RID. */
/** Python -> Java tagged values: n Decimal, i big int, m map with non-str keys, D date, T datetime (UTC wall clock), s set, b bytes (base64), F float[] (as a list), B float[] (from the side buffer), BL List<Double> (from the side buffer), R RID. */
@SuppressWarnings("unchecked")
private Object decodeTag(final String kind, final List<?> tag) {
final Object payload = tag.get(1);
Expand All @@ -425,6 +425,19 @@ private Object decodeTag(final String kind, final List<?> tag) {
f[k] = fp.read((int) off + k);
return f;
}
case "BL": {
// the same side buffer read as a java.util.List of Double: a list of Python floats that float32 holds exactly (the Python
// side checks double -> float32 -> double is the identity), so the engine reads the List<Double> the JSON text would have been
final long off = ((Number) payload).longValue();
final int count = ((Number) tag.get(2)).intValue();
if (buf == 0 || off < 0 || count < 0 || off + count > bufFloats)
throw new IllegalArgumentException("binary parameter outside the side buffer");
final List<Object> list = new ArrayList<>(count);
final CFloatPointer fp = WordFactory.pointer(buf);
for (int k = 0; k < count; k++)
list.add((double) fp.read((int) off + k));
return list;
}
case "n":
return new BigDecimal((String) payload);
case "i":
Expand Down
76 changes: 70 additions & 6 deletions src/arcadedb/_native/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,48 @@
TAG = "\u0001"
_EPOCH_NAIVE = datetime(1970, 1, 1)

# A list of this many Python floats or more may cross in the float32 side buffer (see _exact_float_list). A shorter one is cheaper
# as JSON text, and a short IN list is the common use of a list parameter.
_FLOAT_LIST_MIN = 64


class _ExactFloats:
"""A list of Python floats that float32 holds exactly: ``f32`` is the side-buffer form, ``values`` the list it stands for."""

__slots__ = ("f32", "values")

def __init__(self, f32, values):
self.f32 = f32
self.values = values


def _exact_float_list(value):
"""``value`` as an :class:`_ExactFloats` when it is a list of at least ``_FLOAT_LIST_MIN`` Python floats (no int, bool, None,
numpy scalar) that all survive the round trip double -> float32 -> double unchanged; None otherwise.

The engine then reads the same ``List<Double>`` it read from the JSON text, value for value: the check is exact, never a
tolerance. A list from a float32 model (``ndarray.tolist()``) passes; a list of doubles that float32 would round (0.1), NaN,
or an infinity fails the check and stays JSON text, so no result changes. The text costs 340 ns a number
(611 us for 1536), the buffer 60 us.
"""
if (
_np is None
or len(value) < _FLOAT_LIST_MIN
or set(map(type, value)) != _FLOAT_ONLY
):
return None
doubles = _np.array(value, dtype=_np.float64)
with _np.errstate(
over="ignore"
): # a double beyond float32 becomes an infinity, which fails the check below
f32 = doubles.astype(_np.float32)
if (
not _np.array_equal(f32.astype(_np.float64), doubles)
or not _np.isfinite(f32).all()
):
return None # NaN and the infinities stay text: they are what the engine's JSON reader decides about, not this check
return _ExactFloats(f32, value)


def _default(value, _bin=None):
"""json.dumps hook: the values JSON cannot carry, as tagged objects (see Json.java).
Expand All @@ -23,6 +65,13 @@ def _default(value, _bin=None):
"""
if isinstance(value, RID):
return {TAG: ["R", value.text]}
if isinstance(value, _ExactFloats):
if _bin is None:
return value.values
off = _bin[1]
_bin[0].append(value.f32)
_bin[1] = off + value.f32.size
return {TAG: ["BL", off, value.f32.size]}
if isinstance(value, Decimal):
return {TAG: ["n", str(value)]}
if isinstance(value, datetime):
Expand Down Expand Up @@ -65,23 +114,38 @@ def _default(value, _bin=None):


_PLAIN = frozenset((str, int, float, bool, type(None)))
_FLOAT_ONLY = frozenset((float,))
_dumps = json.dumps
_fast = os.environ.get("ARCADEDB_NATIVE_FASTENC", "1") != "0"


def _prep(value):
def _prep(value, binary=False):
"""A value ready for json.dumps: a dict with a key that is not a str becomes a tagged ``[[k, v], ..]`` map (JSON would turn
the key into text; the JPype path keeps it), and containers are walked for such dicts. Plain scalars pass untouched.

``binary``: a long list of floats that float32 holds exactly is returned as an :class:`_ExactFloats` for the side buffer.
"""
t = type(value)
if t in _PLAIN:
return value
if isinstance(value, Mapping):
if all(type(k) is str for k in value):
return {k: _prep(v) for k, v in value.items()}
return {TAG: ["m", [[_prep(k), _prep(v)] for k, v in value.items()]]}
return {k: _prep(v, binary) for k, v in value.items()}
return {
TAG: [
"m",
[[_prep(k, binary), _prep(v, binary)] for k, v in value.items()],
]
}
if t is list or t is tuple:
return [_prep(v) for v in value]
if len(value) > 8:
if binary and t is list:
exact = _exact_float_list(value)
if exact is not None:
return exact
if set(map(type, value)) <= _PLAIN:
return value # nothing inside to rewrite: skip the per-element walk (69 us for 1536 numbers)
return [_prep(v, binary) for v in value]
return value


Expand All @@ -102,10 +166,10 @@ def encode_params(args, binary=False):
if len(values) == 1 and isinstance(values[0], Mapping):
named = values[0]
if not all(type(v) in plain for v in named.values()):
named = {k: _prep(v) for k, v in named.items()}
named = {k: _prep(v, binary) for k, v in named.items()}
return _dump({"named": dict(named)}, binary)
if not all(type(v) in plain for v in values):
values = [_prep(v) for v in values]
values = [_prep(v, binary) for v in values]
return _dump({"pos": list(values)}, binary)


Expand Down
38 changes: 38 additions & 0 deletions src/arcadedb/type_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ class _PythonToJavaTypes(NamedTuple):
# Element types convert_python_to_java hands to the JVM as one Object[].
_BULK_SCALAR_TYPES = frozenset((int, float, str, bool, type(None)))

# A list of this many Python floats or more crosses as ONE double[] and is boxed on the Java side (see _boxed_doubles); a shorter one
# is cheaper through the plain Object[] path (measured: the two cost the same at about 20 numbers on the laptop).
_FLOAT_LIST_MIN = 32
_FLOAT_ONLY = frozenset((float,))
_DOUBLE_ARRAY = None


def _boxed_doubles(values, java_python_types):
"""``values`` (a list of Python floats, or a float64 ndarray) as the ``java.util.ArrayList`` of ``Double`` the element-wise
conversion builds, from ONE ``double[]``.

JPype boxes a Python float with one call to the JVM (about 0.33 us each, 505 us for a 1536-d vector); the buffer crosses
once (20 to 45 us) and ``DoubleStream.boxed()`` boxes on the Java side. The result is the same ``ArrayList<Double>``, value for
value (NaN, the infinities and -0.0 included), so a statement reads it exactly as it read the boxed list.
"""
global _DOUBLE_ARRAY
if _DOUBLE_ARRAY is None:
_DOUBLE_ARRAY = jpype.JArray(jpype.JDouble)
arrays = java_python_types.arrays
return java_python_types.array_list(
arrays.stream(_DOUBLE_ARRAY(values)).boxed().toList()
)


_TYPE_CACHE = {
"java_core": None,
"java_collections": None,
Expand Down Expand Up @@ -519,6 +543,16 @@ def convert_python_to_java(value: Any) -> Any:
# (exact values: a float64 array keeps its doubles).
if value.ndim == 1 and value.dtype == _np.float32:
return jpype.JArray(jpype.JFloat)(value)
if (
value.ndim == 1
and value.dtype == _np.float64
and value.dtype.isnative
and value.size >= _FLOAT_LIST_MIN
):
# the same ArrayList<Double> value.tolist() becomes, from one double[] instead of one boxed object per number
java_python_types = _get_java_python_types()
if java_python_types is not None:
return _boxed_doubles(_np.ascontiguousarray(value), java_python_types)
return convert_python_to_java(value.tolist())

java_python_types = _get_java_python_types()
Expand Down Expand Up @@ -554,6 +588,10 @@ def convert_python_to_java(value: Any) -> Any:
# Long, float -> Double, str, bool -> Boolean, None -> null) and
# raises the same OverflowError past 64 bits; anything else (nested
# collections, dates, Decimal, numpy scalars) takes the loop below.
# A list of Python floats and nothing else (no int, bool, None, str, numpy scalar) crosses as one double[] and is boxed
# in Java: 505 us -> 44 us for 1536 numbers. Anything mixed keeps the element-wise path, so an int stays a Long.
if len(value) >= _FLOAT_LIST_MIN and set(map(type, value)) == _FLOAT_ONLY:
return _boxed_doubles(value, java_python_types)
if all(type(item) in _BULK_SCALAR_TYPES for item in value):
return java_python_types.array_list(
java_python_types.arrays.asList(jpype.JArray(jpype.JObject)(value))
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"test_build_scripts.py": "build scripts of the JVM wheel",
"test_build_no_uv_refresh.py": "build scripts of the JVM wheel",
"test_build_wheel_check.py": "build scripts of the JVM wheel",
"test_query_vector_params_jvm.py": "the java.util objects the JVM build's parameter conversion builds",
}
if BACKEND == "native" and not os.environ.get("NATIVE_PARITY_ALL"):
collect_ignore.extend(NATIVE_NOT_APPLICABLE)
Expand Down
Loading
Loading