kmath/docs/nd-structure.md

150 lines
4.6 KiB
Markdown
Raw Normal View History

# ND-structure generation and operations
**TODO**
# Performance for n-dimensional structures operations
One of the most sought after features of mathematical libraries is the high-performance operations on n-dimensional
structures. In `kmath` performance depends on which particular context was used for operation.
Let us consider following contexts:
2024-03-27 09:11:12 +03:00
```kotlin
2019-01-07 17:18:31 +03:00
// automatically build context most suited for given type.
2021-03-16 21:17:26 +03:00
val autoField = NDField.auto(DoubleField, dim, dim)
// specialized nd-field for Double. It works as generic Double field as well.
2019-06-08 16:30:06 +03:00
val specializedField = NDField.real(dim, dim)
2019-01-07 17:18:31 +03:00
//A generic boxing field. It should be used for objects, not primitives.
2021-03-16 21:17:26 +03:00
val genericField = NDField.buffered(DoubleField, dim, dim)
2019-01-07 17:18:31 +03:00
```
2024-03-27 09:11:12 +03:00
Now let us perform several tests and see, which implementation is best suited for each case:
2019-01-07 17:18:31 +03:00
## Test case
To test performance we will take 2d-structures with `dim = 1000` and add a structure filled with `1.0`
2019-01-07 17:18:31 +03:00
to it `n = 1000` times.
## Specialized
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
The code to run this looks like:
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
```kotlin
specializedField.run {
2019-06-08 16:30:06 +03:00
var res: NDBuffer<Double> = one
2019-01-07 17:18:31 +03:00
repeat(n) {
res += 1.0
}
}
```
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
The performance of this code is the best of all tests since it inlines all operations and is specialized for operation
with doubles. We will measure everything else relative to this one, so time for this test will be `1x` (real time
on my computer is about 4.5 seconds). The only problem with this approach is that it requires specifying type
from the beginning. Everyone does so anyway, so it is the recommended approach.
2019-01-07 17:18:31 +03:00
## Automatic
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
Let's do the same with automatic field inference:
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
```kotlin
autoField.run {
var res = one
repeat(n) {
res += 1.0
}
}
```
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
Ths speed of this operation is approximately the same as for specialized case since `NDField.auto` just
returns the same `RealNDField` in this case. Of course, it is usually better to use specialized method to be sure.
2019-01-07 17:18:31 +03:00
## Lazy
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
Lazy field does not produce a structure when asked, instead it generates an empty structure and fills it on-demand
using coroutines to parallelize computations.
When one calls
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
```kotlin
lazyField.run {
var res = one
repeat(n) {
res += 1.0
}
}
```
2024-03-27 09:11:12 +03:00
The result will be calculated almost immediately but the result will be empty. To get the full result
2019-01-07 17:18:31 +03:00
structure one needs to call all its elements. In this case computation overhead will be huge. So this field never
should be used if one expects to use the full result structure. Though if one wants only small fraction, it could
save a lot of time.
This field still could be used with reasonable performance if call code is changed:
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
```kotlin
lazyField.run {
val res = one.map {
var c = 0.0
repeat(n) {
c += 1.0
}
c
}
res.elements().forEach { it.second }
}
```
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
In this case it completes in about `4x-5x` time due to boxing.
## Boxing
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
The boxing field produced by
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
```kotlin
genericField.run {
2019-06-08 16:30:06 +03:00
var res: NDBuffer<Double> = one
2019-01-07 17:18:31 +03:00
repeat(n) {
res += 1.0
}
}
```
2024-03-27 09:11:12 +03:00
is the slowest one, because it requires boxing and unboxing the `double` on each operation. It takes about
2019-01-07 17:18:31 +03:00
`15x` time (**TODO: there seems to be a problem here, it should be slow, but not that slow**). This field should
never be used for primitives.
## Element operation
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
Let us also check the speed for direct operations on elements:
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
```kotlin
var res = genericField.one
repeat(n) {
res += 1.0
}
```
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
One would expect to be at least as slow as field operation, but in fact, this one takes only `2x` time to complete.
It happens, because in this particular case it does not use actual `NDField` but instead calculated directly
via extension function.
## What about python?
Usually it is bad idea to compare the direct numerical operation performance in different languages, but it hard to
work completely without frame of reference. In this case, simple numpy code:
2024-03-27 09:11:12 +03:00
2019-01-07 17:18:31 +03:00
```python
import numpy as np
2019-01-07 17:18:31 +03:00
res = np.ones((1000,1000))
for i in range(1000):
res = res + 1.0
```
2024-03-27 09:11:12 +03:00
gives the completion time of about `1.1x`, which means that specialized kotlin code in fact is working faster (I think
it is
2019-01-07 17:18:31 +03:00
because better memory management). Of course if one writes `res += 1.0`, the performance will be different,
but it would be different case, because numpy overrides `+=` with in-place operations. In-place operations are
2019-01-07 17:18:31 +03:00
available in `kmath` with `MutableNDStructure` but there is no field for it (one can still work with mapping
functions).