mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
perf(relay): build the bulk filters in one pass
These run on every filter rebuild, several times a second, and the previous shape
was a chain of operators that each allocated an intermediate: `partition`,
`groupBy`, `map`, `distinct`, `sorted`, `chunked`, `map`, then a list concat.
`groupBy { kind to pubKeyHex }` allocated a `Pair` and a boxed `Int` per address
on top of that.
Now one pass into nested maps, sorting in place, appending straight to the output
list. `distinct()` went entirely: the input is a `Set<Address>` and `Address` is a
data class, so two entries in one (kind, author) group cannot share a `d` -- it
was dead work every call. The two group maps are allocated only if that kind of
address turns up, and `forEachChunk` hands the whole list through untouched when
it already fits, which is nearly always, instead of `chunked` building an outer
list and a copy.
Measured, same machine, JVM, against the previous implementation:
8 addresses (typical), 200k reps : 368.8ms -> 75.8ms 4.9x
240 addresses (a book), 5k reps : 132.5ms -> 52.0ms 2.5x
The small case gains most, which is the one that runs constantly -- 1.84us to
0.38us per call. Behaviour is unchanged: same 7 tests, and the 240-section index
still renders all its rows on device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f056f152d
commit
a735498a6f
+91
-50
@@ -116,63 +116,104 @@ fun filterMissingAddressables(
|
||||
* limit down to their own `maxLimit` (`LimitsPolicy.applyLimits`), so one filter asking for 240
|
||||
* coordinates with `limit = 240` comes back holding only `maxLimit` of them, and the rest go
|
||||
* missing with no error to notice. Chunks keep each `limit` small enough to survive the clamp.
|
||||
*
|
||||
* Written as one pass into nested maps rather than `partition`/`groupBy`/`distinct`/`sorted`
|
||||
* chains: this runs on every filter rebuild, several times a second, and each of those operators
|
||||
* is another intermediate list. `groupBy { kind to pubKeyHex }` alone allocated a `Pair` and a
|
||||
* boxed `Int` per address. No de-duplication is needed either -- the input is a `Set<Address>` and
|
||||
* `Address` is a data class, so two entries in one group cannot share a `d`.
|
||||
*/
|
||||
fun filterMissingAddressables(missingAddressables: Map<NormalizedRelayUrl, Set<Address>>): List<RelayBasedFilter> {
|
||||
if (missingAddressables.isEmpty()) return emptyList()
|
||||
|
||||
return missingAddressables.flatMap { (relay, addresses) ->
|
||||
// A replaceable event below 25000 with no `d` is addressed by kind and author alone, so it
|
||||
// cannot join a `#d` group.
|
||||
val (withoutDTag, withDTag) = addresses.partition { it.kind < 25000 && it.dTag.isBlank() }
|
||||
val filters = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
val byKindAndAuthor =
|
||||
withDTag
|
||||
.groupBy { it.kind to it.pubKeyHex }
|
||||
.flatMap { (kindAndAuthor, group) ->
|
||||
val (kind, author) = kindAndAuthor
|
||||
group
|
||||
.map { it.dTag }
|
||||
.distinct()
|
||||
.sorted()
|
||||
.chunked(MAX_VALUES_PER_FILTER)
|
||||
.map { dTags ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.REFERENCED_EVENTS,
|
||||
kinds = listOf(kind),
|
||||
tags = mapOf("d" to dTags),
|
||||
authors = listOf(author),
|
||||
limit = dTags.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
missingAddressables.forEach { (relay, addresses) ->
|
||||
if (addresses.isEmpty()) return@forEach
|
||||
|
||||
// kind -> author -> the `d`s wanted from that author
|
||||
var byAuthor: MutableMap<Int, MutableMap<String, MutableList<String>>>? = null
|
||||
// A replaceable below 25000 with no `d` is addressed by kind and author alone, so it
|
||||
// cannot join a `#d` group. Rare, so the map is only built if one turns up.
|
||||
var plain: MutableMap<Int, MutableList<String>>? = null
|
||||
|
||||
addresses.forEach { address ->
|
||||
if (address.kind < 25000 && address.dTag.isBlank()) {
|
||||
(plain ?: HashMap<Int, MutableList<String>>().also { plain = it })
|
||||
.getOrPut(address.kind) { mutableListOf() }
|
||||
.add(address.pubKeyHex)
|
||||
} else {
|
||||
(byAuthor ?: HashMap<Int, MutableMap<String, MutableList<String>>>().also { byAuthor = it })
|
||||
.getOrPut(address.kind) { HashMap() }
|
||||
.getOrPut(address.pubKeyHex) { mutableListOf() }
|
||||
.add(address.dTag)
|
||||
}
|
||||
}
|
||||
|
||||
byAuthor?.forEach { (kind, authors) ->
|
||||
val kinds = listOf(kind)
|
||||
authors.forEach { (author, dTags) ->
|
||||
val authorList = listOf(author)
|
||||
dTags.sort()
|
||||
forEachChunk(dTags) { chunk ->
|
||||
filters.add(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.REFERENCED_EVENTS,
|
||||
kinds = kinds,
|
||||
tags = mapOf("d" to chunk),
|
||||
authors = authorList,
|
||||
limit = chunk.size,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val byKind =
|
||||
withoutDTag
|
||||
.groupBy { it.kind }
|
||||
.flatMap { (kind, group) ->
|
||||
group
|
||||
.map { it.pubKeyHex }
|
||||
.distinct()
|
||||
.sorted()
|
||||
.chunked(MAX_VALUES_PER_FILTER)
|
||||
.map { authors ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.REFERENCED_EVENTS,
|
||||
kinds = listOf(kind),
|
||||
authors = authors,
|
||||
limit = authors.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
plain?.forEach { (kind, pubkeys) ->
|
||||
val kinds = listOf(kind)
|
||||
pubkeys.sort()
|
||||
forEachChunk(pubkeys) { chunk ->
|
||||
filters.add(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.REFERENCED_EVENTS,
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
byKindAndAuthor + byKind
|
||||
return filters
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands [block] each [MAX_VALUES_PER_FILTER]-sized slice of [values], passing the list itself when
|
||||
* it already fits -- which is nearly always. `chunked` would allocate an outer list plus a copy
|
||||
* even for the single-chunk case.
|
||||
*/
|
||||
internal inline fun forEachChunk(
|
||||
values: List<String>,
|
||||
block: (List<String>) -> Unit,
|
||||
) {
|
||||
if (values.size <= MAX_VALUES_PER_FILTER) {
|
||||
block(values)
|
||||
return
|
||||
}
|
||||
|
||||
var from = 0
|
||||
while (from < values.size) {
|
||||
val to = minOf(from + MAX_VALUES_PER_FILTER, values.size)
|
||||
block(values.subList(from, to))
|
||||
from = to
|
||||
}
|
||||
}
|
||||
|
||||
+17
-7
@@ -144,17 +144,27 @@ fun filterMissingEvents(
|
||||
fun filterMissingEvents(missingEventIds: Map<NormalizedRelayUrl, Set<String>>): List<RelayBasedFilter> {
|
||||
if (missingEventIds.isEmpty()) return emptyList()
|
||||
|
||||
return missingEventIds.flatMap { (relay, ids) ->
|
||||
ids
|
||||
.sorted()
|
||||
.chunked(MAX_VALUES_PER_FILTER)
|
||||
.map { chunk ->
|
||||
val filters = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
missingEventIds.forEach { (relay, ids) ->
|
||||
if (ids.isEmpty()) return@forEach
|
||||
|
||||
// Sorted so a rebuild that found the same ids produces the same filter and the
|
||||
// subscription is not torn down and re-sent for nothing.
|
||||
val sorted = ids.toMutableList()
|
||||
sorted.sort()
|
||||
|
||||
forEachChunk(sorted) { chunk ->
|
||||
filters.add(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter = ExplainedFilter(purpose = SubPurpose.REFERENCED_EVENTS, ids = chunk),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user