Skip to content

Add probing_diversity_penalty_msat to the scorer parameters #3422

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
138 changes: 121 additions & 17 deletions lightning/src/routing/scoring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,9 @@ where L::Target: Logger {
network_graph: G,
logger: L,
channel_liquidities: ChannelLiquidities,
/// The last time we were given via a [`ScoreUpdate`] method. This does not imply that we've
/// decayed every liquidity bound up to that time.
last_update_time: Duration,
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, why not last_update_duration_since_epoch?

}
/// Container for live and historical liquidity bounds for each channel.
#[derive(Clone)]
Expand Down Expand Up @@ -745,6 +748,29 @@ pub struct ProbabilisticScoringFeeParameters {
///
/// Default value: false
pub linear_success_probability: bool,

/// In order to ensure we have knowledge for as many paths as possible, when probing it makes
/// sense to bias away from channels for which we have very recent data.
///
/// This value is a penalty that is applied based on the last time that we updated the bounds
/// on the available liquidity in a channel. The specified value is the maximum penalty that
/// will be applied.
///
/// It obviously does not make sense to assign a non-0 value here unless you are using the
/// pathfinding result for background probing.
Copy link
Contributor

Choose a reason for hiding this comment

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

This way of probing where you set this exploration/exploitation trade-off is just one way to do it, isn't it?

From https://lightningdevkit.org/probing:

Recall that earlier, we discussed the concept of using multiple scorers. One scorer that we actually save results to and another un-updated scorer that can be used to identify routes with a "beginner's mind" - so to speak. If we'd like to leverage this approach, we can pass an un-updated scorer here and then update a different scorer with the results of the probe.

This is a different approach where no scores are used for probe route selection, which is different again from what is in the PR description (using a single scorer).

What's your current thinking on what's best?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, certainly depends on your goal, but I think one model of probing I've kinda come around to is the idea of finding "backup routes" to destination you will likely pay in the future. ie its great when we repeatedly pay a destination and we have a route we always use that works basically all the time, but what happens when that route suddenly fails? Now we have no data on any other path because we're always paying over that same route.

This PR is certainly not a thorough way to get good "backup routes", of course, but with regular probing my gut says it should do at least an okay job.

Copy link
Contributor

Choose a reason for hiding this comment

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

I can follow your thoughts there, but I am quite uncomfortable with the level of gut feeling involved in these decisions. This is probably something that keeps coming back. In #2709 we were also guessing a bit at the best approach, and there are probably more areas where it's not very clear what the best approach or parameters are.

It does make me think more about the idea of setting up a test framework that involves a virtual network of nodes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yea, there's definitely a bit of a gut approach here, and a sim-framework might help, but the amount of gut-feelings that would go into building a sim-framework, I assume, would end up with more gut-result than even this :/. IMO we'd be better off A/B testing two nodes with identical channels to the same peers probing on the same interval and making test payments on the same interval to evaluate such things, but that requires a lot of time to get results.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe that is true. With a sim network though, I think the discussion will move to a higher level. We'd discuss whether the network is realistic enough, and parameters / probe methods / pathfinding algorithms will follow from that.

To me, A/B testing sounds even more prone to getting non-representative results and is probably too slow to scan parameter spaces.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I fear that in a sim network the impact of guesses/gut-informed decisions is multiplicative (each guess compounds the effects of previous ones), but the only way to find out would be to build it and see what we get, plus to compare with reality (via some dataset...not sure what datset...). Maybe comparing with my probing dataset would work, when we shipped the new scorer in 0.1 a data scientist who works on c= helped run some analysis and at least found that our results were robust across a number of dimensions (channel size, etc), so there's at least some reason to think that those results are moderately representative of the broader network.

///
/// Specifically, the following penalty is applied
/// `probing_diversity_penalty_msat * max(0, (86400 - current time + last update))^2 / 86400^2` is
Copy link
Contributor

Choose a reason for hiding this comment

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

Just talked offline with Joost a bit about this and during that I wondered what a good magic value would be for the penalty, i.e, from what threshold does it actually have an impact on diversity of the picked paths?

Could we maybe have the applied penalty be proportional to the amount of datapoints tracked (mayb in the last day)? This way, even if we'd start with the penalty having no impact on diversity and we'd probe the same path over and over again, it would accrue over time and eventually the applied penalty would be so big that it would have an impact, having us moving on to the next likely path at least?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Just talked offline with Joost a bit about this and during that I wondered what a good magic value would be for the penalty, i.e, from what threshold does it actually have an impact on diversity of the picked paths?

I set mine to 100_000. 100 sats is about max of where fees lie anyway, so a penalty between 25 sats (for 12 hours) and 100 sats (absolute max) should have a material impact. In general, I'm not sure I understand your question - its the same type of penalty as any of our other penalties, and we have to pick penalties which appropriately prefer other paths which have lower fees, ie they need to be somehow proportional to fees on the network.

Could we maybe have the applied penalty be proportional to the amount of datapoints tracked (mayb in the last day)? This way, even if we'd start with the penalty having no impact on diversity and we'd probe the same path over and over again, it would accrue over time and eventually the applied penalty would be so big that it would have an impact, having us moving on to the next likely path at least?

We currently don't track any information that would allow us to do that, and tracking it would probably be nontrivial in size (eg a list of the last N times, as u64s, we got a result for each channel). I'm not entirely convinced that makes a difference, though - the theory here is that we probably don't need more results for a channel than once or twice a day if we're probing constantly over the course of months. Achieving that by calculating the number of times we probed a channel in the last day vs just looking at the last time we probed a channel should come out in the wash.

///
/// As this is a maximum value, when setting this you should consider it in relation to the
/// other values set to ensure that, at maximum, we strongly avoid paths which we recently
/// tried (similar to if they have a low success probability). For example, you might set this
/// to be the sum of [`Self::base_penalty_msat`] and
/// [`Self::historical_liquidity_penalty_multiplier_msat`] (plus some multiple of their
/// corresponding `amount_multiplier`s).
///
/// Default value: 0
pub probing_diversity_penalty_msat: u64,
}

impl Default for ProbabilisticScoringFeeParameters {
Expand All @@ -760,6 +786,7 @@ impl Default for ProbabilisticScoringFeeParameters {
historical_liquidity_penalty_multiplier_msat: 10_000,
historical_liquidity_penalty_amount_multiplier_msat: 1_250,
linear_success_probability: false,
probing_diversity_penalty_msat: 0,
}
}
}
Expand Down Expand Up @@ -814,6 +841,7 @@ impl ProbabilisticScoringFeeParameters {
anti_probing_penalty_msat: 0,
considered_impossible_penalty_msat: 0,
linear_success_probability: true,
probing_diversity_penalty_msat: 0,
}
}
}
Expand Down Expand Up @@ -901,17 +929,11 @@ struct ChannelLiquidity {
/// Time when the historical liquidity bounds were last modified as an offset against the unix
/// epoch.
offset_history_last_updated: Duration,
}

// Check that the liquidity HashMap's entries sit on round cache lines.
//
// Specifically, the first cache line will have the key, the liquidity offsets, and the total
// points tracked in the historical tracker.
//
// The next two cache lines will have the historical points, which we only access last during
// scoring, followed by the last_updated `Duration`s (which we do not need during scoring).
const _LIQUIDITY_MAP_SIZING_CHECK: usize = 192 - ::core::mem::size_of::<(u64, ChannelLiquidity)>();
const _LIQUIDITY_MAP_SIZING_CHECK_2: usize = ::core::mem::size_of::<(u64, ChannelLiquidity)>() - 192;
/// The last time when the liquidity bounds were updated with new payment information (i.e.
/// ignoring decays).
last_datapoint_time: Duration,
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we name this last_datapoint_duration_since_epoch to clarify the format since Duration is a ubiquitous type that doesn't tell us how time is measured?

Copy link
Contributor

@tnull tnull Apr 7, 2025

Choose a reason for hiding this comment

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

LOL, I just realized this is exactly what @joostjager had you change the variable names from in his naming nits above.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yea, I figured _time was pretty clear (what else could it possibly be?)

}

/// A snapshot of [`ChannelLiquidity`] in one direction assuming a certain channel capacity.
struct DirectedChannelLiquidity<L: Deref<Target = u64>, HT: Deref<Target = HistoricalLiquidityTracker>, T: Deref<Target = Duration>> {
Expand All @@ -921,6 +943,7 @@ struct DirectedChannelLiquidity<L: Deref<Target = u64>, HT: Deref<Target = Histo
capacity_msat: u64,
last_updated: T,
offset_history_last_updated: T,
last_datapoint_time: T,
}

impl<G: Deref<Target = NetworkGraph<L>>, L: Deref> ProbabilisticScorer<G, L> where L::Target: Logger {
Expand All @@ -932,6 +955,7 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Deref> ProbabilisticScorer<G, L> whe
network_graph,
logger,
channel_liquidities: ChannelLiquidities::new(),
last_update_time: Duration::from_secs(0),
}
}

Expand Down Expand Up @@ -1163,6 +1187,7 @@ impl ChannelLiquidity {
liquidity_history: HistoricalLiquidityTracker::new(),
last_updated,
offset_history_last_updated: last_updated,
last_datapoint_time: last_updated,
}
}

Expand Down Expand Up @@ -1195,6 +1220,7 @@ impl ChannelLiquidity {
capacity_msat,
last_updated: &self.last_updated,
offset_history_last_updated: &self.offset_history_last_updated,
last_datapoint_time: &self.last_datapoint_time,
}
}

Expand All @@ -1218,6 +1244,7 @@ impl ChannelLiquidity {
capacity_msat,
last_updated: &mut self.last_updated,
offset_history_last_updated: &mut self.offset_history_last_updated,
last_datapoint_time: &mut self.last_datapoint_time,
}
}

Expand Down Expand Up @@ -1385,7 +1412,7 @@ DirectedChannelLiquidity< L, HT, T> {
/// Returns a liquidity penalty for routing the given HTLC `amount_msat` through the channel in
/// this direction.
fn penalty_msat(
&self, amount_msat: u64, inflight_htlc_msat: u64,
&self, amount_msat: u64, inflight_htlc_msat: u64, last_update_time: Duration,
score_params: &ProbabilisticScoringFeeParameters,
) -> u64 {
let total_inflight_amount_msat = amount_msat.saturating_add(inflight_htlc_msat);
Expand Down Expand Up @@ -1467,6 +1494,18 @@ DirectedChannelLiquidity< L, HT, T> {
}
}

if score_params.probing_diversity_penalty_msat != 0 {
// We use `last_update_time` as a stand-in for the current time as we don't want to
// fetch the current time in every score call (slowing things down substantially on
// some platforms where a syscall is required), don't want to add an unnecessary `std`
Copy link
Contributor

Choose a reason for hiding this comment

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

Re-reading this now, I am not so sure anymore that it is important to avoid std for probing specifically. It seems likely that probing is done only on servers that have std?

For attr failures we do a similar thing where we only report hold time data if the platform has std. This would avoid the extra disk writes, and make for a simpler changeset in general. Not just lines of code, but also reasoning about the time approximation. This comment basically.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm incredibly skeptical of making exceptions for more and more use-cases. While its true that most active probers will be built with std, I'd be pretty skeptical of calling it a guarantee. There's not much reason to think that some edge-nodes will never decide to do some probing to improve their graph knowledge, and even if we assume they never would we could also end up at some point in the future having forwarding nodes that happen to be running in a weird environment (eg some high-security high-funds node running in a secure enclave or so that might not like regular std SystemTime).

We can cut some corners here and there but diverging too much in terms of functionality between std and not really dilutes the value of LDK, IMO.

Copy link
Contributor

@joostjager joostjager Apr 3, 2025

Choose a reason for hiding this comment

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

The alternative approach is to implement the minimum that is needed today for probing and don't support non-std probing diversity yet. Then if any of the potential requirements that you list above materializes, add it then. In the case of this PR, it wouldn't increase the total amount of work much. In this PR you'd just fetch time, and then in the potential future non-std extension, delete that one line and add the time derivation.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'd much rather do it right the first time than have a user complain that something isn't working in their build and have to rush out a release to get it working. If its a ton of complexity or not practical its one thing, but its fairly straightforward here.

// requirement. Assuming we're probing somewhat regularly, it should reliably be close
// to the current time, (and using the last the last time we probed is also fine here).
let time_since_update = last_update_time.saturating_sub(*self.last_datapoint_time);
let mul = Duration::from_secs(60 * 60 * 24).saturating_sub(time_since_update).as_secs();
let penalty = score_params.probing_diversity_penalty_msat.saturating_mul(mul * mul);
res = res.saturating_add(penalty / ((60 * 60 * 24) * (60 * 60 * 24)));
}

res
}

Expand Down Expand Up @@ -1564,6 +1603,7 @@ DirectedChannelLiquidity<L, HT, T> {
*self.max_liquidity_offset_msat = 0;
}
*self.last_updated = duration_since_epoch;
*self.last_datapoint_time = duration_since_epoch;
}

/// Adjusts the upper bound of the channel liquidity balance in this direction.
Expand All @@ -1573,6 +1613,7 @@ DirectedChannelLiquidity<L, HT, T> {
*self.min_liquidity_offset_msat = 0;
}
*self.last_updated = duration_since_epoch;
*self.last_datapoint_time = duration_since_epoch;
}
}

Expand Down Expand Up @@ -1616,11 +1657,12 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Deref> ScoreLookUp for Probabilistic
}

let capacity_msat = usage.effective_capacity.as_msat();
let time = self.last_update_time;
self.channel_liquidities
.get(scid)
.unwrap_or(&ChannelLiquidity::new(Duration::ZERO))
.as_directed(&source, &target, capacity_msat)
.penalty_msat(usage.amount_msat, usage.inflight_htlc_msat, score_params)
.penalty_msat(usage.amount_msat, usage.inflight_htlc_msat, time, score_params)
.saturating_add(anti_probing_penalty_msat)
.saturating_add(base_penalty_msat)
}
Expand Down Expand Up @@ -1666,6 +1708,7 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Deref> ScoreUpdate for Probabilistic
}
if at_failed_channel { break; }
}
self.last_update_time = duration_since_epoch;
}

fn payment_path_successful(&mut self, path: &Path, duration_since_epoch: Duration) {
Expand Down Expand Up @@ -1693,6 +1736,7 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Deref> ScoreUpdate for Probabilistic
hop.short_channel_id);
}
}
self.last_update_time = duration_since_epoch;
}

fn probe_failed(&mut self, path: &Path, short_channel_id: u64, duration_since_epoch: Duration) {
Expand All @@ -1705,6 +1749,7 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Deref> ScoreUpdate for Probabilistic

fn time_passed(&mut self, duration_since_epoch: Duration) {
self.channel_liquidities.time_passed(duration_since_epoch, self.decay_params);
self.last_update_time = duration_since_epoch;
}
}

Expand Down Expand Up @@ -2361,11 +2406,16 @@ ReadableArgs<(ProbabilisticScoringDecayParameters, G, L)> for ProbabilisticScore
) -> Result<Self, DecodeError> {
let (decay_params, network_graph, logger) = args;
let channel_liquidities = ChannelLiquidities::read(r)?;
let mut last_update_time = Duration::from_secs(0);
for (_, liq) in channel_liquidities.0.iter() {
last_update_time = cmp::max(last_update_time, liq.last_updated);
}
Ok(Self {
decay_params,
network_graph,
logger,
channel_liquidities,
last_update_time,
})
}
}
Expand All @@ -2382,6 +2432,7 @@ impl Writeable for ChannelLiquidity {
(5, self.liquidity_history.writeable_min_offset_history(), required),
(7, self.liquidity_history.writeable_max_offset_history(), required),
(9, self.offset_history_last_updated, required),
(11, self.last_datapoint_time, required),
});
Ok(())
}
Expand All @@ -2398,6 +2449,7 @@ impl Readable for ChannelLiquidity {
let mut max_liquidity_offset_history: Option<HistoricalBucketRangeTracker> = None;
let mut last_updated = Duration::from_secs(0);
let mut offset_history_last_updated = None;
let mut last_datapoint_time = None;
read_tlv_fields!(r, {
(0, min_liquidity_offset_msat, required),
(1, legacy_min_liq_offset_history, option),
Expand All @@ -2407,6 +2459,7 @@ impl Readable for ChannelLiquidity {
(5, min_liquidity_offset_history, option),
(7, max_liquidity_offset_history, option),
(9, offset_history_last_updated, option),
(11, last_datapoint_time, option),
});

if min_liquidity_offset_history.is_none() {
Expand All @@ -2431,6 +2484,7 @@ impl Readable for ChannelLiquidity {
),
last_updated,
offset_history_last_updated: offset_history_last_updated.unwrap_or(last_updated),
last_datapoint_time: last_datapoint_time.unwrap_or(last_updated),
})
}
}
Expand Down Expand Up @@ -2604,19 +2658,20 @@ mod tests {
let logger = TestLogger::new();
let last_updated = Duration::ZERO;
let offset_history_last_updated = Duration::ZERO;
let last_datapoint_time = Duration::ZERO;
let network_graph = network_graph(&logger);
let decay_params = ProbabilisticScoringDecayParameters::default();
let mut scorer = ProbabilisticScorer::new(decay_params, &network_graph, &logger)
.with_channel(42,
ChannelLiquidity {
min_liquidity_offset_msat: 700, max_liquidity_offset_msat: 100,
last_updated, offset_history_last_updated,
last_updated, offset_history_last_updated, last_datapoint_time,
liquidity_history: HistoricalLiquidityTracker::new(),
})
.with_channel(43,
ChannelLiquidity {
min_liquidity_offset_msat: 700, max_liquidity_offset_msat: 100,
last_updated, offset_history_last_updated,
last_updated, offset_history_last_updated, last_datapoint_time,
liquidity_history: HistoricalLiquidityTracker::new(),
});
let source = source_node_id();
Expand Down Expand Up @@ -2683,13 +2738,14 @@ mod tests {
let logger = TestLogger::new();
let last_updated = Duration::ZERO;
let offset_history_last_updated = Duration::ZERO;
let last_datapoint_time = Duration::ZERO;
let network_graph = network_graph(&logger);
let decay_params = ProbabilisticScoringDecayParameters::default();
let mut scorer = ProbabilisticScorer::new(decay_params, &network_graph, &logger)
.with_channel(42,
ChannelLiquidity {
min_liquidity_offset_msat: 200, max_liquidity_offset_msat: 400,
last_updated, offset_history_last_updated,
last_updated, offset_history_last_updated, last_datapoint_time,
liquidity_history: HistoricalLiquidityTracker::new(),
});
let source = source_node_id();
Expand Down Expand Up @@ -2743,13 +2799,14 @@ mod tests {
let logger = TestLogger::new();
let last_updated = Duration::ZERO;
let offset_history_last_updated = Duration::ZERO;
let last_datapoint_time = Duration::ZERO;
let network_graph = network_graph(&logger);
let decay_params = ProbabilisticScoringDecayParameters::default();
let mut scorer = ProbabilisticScorer::new(decay_params, &network_graph, &logger)
.with_channel(42,
ChannelLiquidity {
min_liquidity_offset_msat: 200, max_liquidity_offset_msat: 400,
last_updated, offset_history_last_updated,
last_updated, offset_history_last_updated, last_datapoint_time,
liquidity_history: HistoricalLiquidityTracker::new(),
});
let source = source_node_id();
Expand Down Expand Up @@ -2855,6 +2912,7 @@ mod tests {
let logger = TestLogger::new();
let last_updated = Duration::ZERO;
let offset_history_last_updated = Duration::ZERO;
let last_datapoint_time = Duration::ZERO;
let network_graph = network_graph(&logger);
let params = ProbabilisticScoringFeeParameters {
liquidity_penalty_multiplier_msat: 1_000,
Expand All @@ -2868,7 +2926,7 @@ mod tests {
.with_channel(42,
ChannelLiquidity {
min_liquidity_offset_msat: 40, max_liquidity_offset_msat: 40,
last_updated, offset_history_last_updated,
last_updated, offset_history_last_updated, last_datapoint_time,
liquidity_history: HistoricalLiquidityTracker::new(),
});
let source = source_node_id();
Expand Down Expand Up @@ -4052,6 +4110,52 @@ mod tests {
combined_scorer.scorer.estimated_channel_liquidity_range(42, &target_node_id());
assert_eq!(liquidity_range.unwrap(), (0, 0));
}

#[test]
fn probes_for_diversity() {
// Tests the probing_diversity_penalty_msat is applied
let logger = TestLogger::new();
let network_graph = network_graph(&logger);
let params = ProbabilisticScoringFeeParameters {
probing_diversity_penalty_msat: 1_000_000,
..ProbabilisticScoringFeeParameters::zero_penalty()
};
let decay_params = ProbabilisticScoringDecayParameters {
liquidity_offset_half_life: Duration::from_secs(10),
..ProbabilisticScoringDecayParameters::zero_penalty()
};
let mut scorer = ProbabilisticScorer::new(decay_params, &network_graph, &logger);
let source = source_node_id();

let usage = ChannelUsage {
amount_msat: 512,
inflight_htlc_msat: 0,
effective_capacity: EffectiveCapacity::Total { capacity_msat: 1_024, htlc_maximum_msat: 1_024 },
};
let channel = network_graph.read_only().channel(42).unwrap().to_owned();
let (info, _) = channel.as_directed_from(&source).unwrap();
let candidate = CandidateRouteHop::PublicHop(PublicHopCandidate {
info,
short_channel_id: 42,
});

// Apply some update to set the last-update time to now
scorer.payment_path_failed(&payment_path_for_amount(1000), 42, Duration::ZERO);

// If no time has passed, we get the full probing_diversity_penalty_msat
assert_eq!(scorer.channel_penalty_msat(&candidate, usage, &params), 1_000_000);

// As time passes the penalty decreases.
scorer.time_passed(Duration::from_secs(1));
assert_eq!(scorer.channel_penalty_msat(&candidate, usage, &params), 999_976);

scorer.time_passed(Duration::from_secs(2));
assert_eq!(scorer.channel_penalty_msat(&candidate, usage, &params), 999_953);

// Once we've gotten halfway through the day our penalty is 1/4 the configured value.
scorer.time_passed(Duration::from_secs(86400/2));
assert_eq!(scorer.channel_penalty_msat(&candidate, usage, &params), 250_000);
}
}

#[cfg(ldk_bench)]
Expand Down
Loading