Consequences of annotating every single crate function with `track_caller` attribute

I am writing a wrapper for C++ library and one of the requirements is to catch all exceptions and convert them to Result (similar to how autocxx does things).

Now, just returning a value from a function works, but I would like to annotate it with at least a line where the call failed (if not a callstack).
This I can do with Location and track_caller attribute, but since it is expected that almost every single function can throw an exception, I would need to annotate all of them. And the question is what are the consequences of doing that? Performance issues? Increased binary sized? Something else? It is just that there should be a reason why track_caller is not a default attribute.

Would be glad for any help!

Someone who knows rustc better would be more help, but there's this note in the reference (about fn pointers but it talks about the implementation):

Note: The aforementioned shim for function pointers is necessary because rustc implements track_caller in a codegen context by appending an implicit parameter to the function ABI

So there may be some performance penalty associated with the extra parameter. Maybe consider adding #[inline] as well? Or you could put the track_caller attributes behind a feature.

Thanks!
Actually inline is also one of the attributes I am not sure how to properly handle.
Most of the functions I have follow the logic of:

fn crate_fun(..args) -> crate::Result<Ret> {
    // 1. Marshall arguments to pass them to C++
    // 2. Call 'extern "C"' function
    // 3. Check a global static whether an exception has happened
        // 3.1. Here I want to add `Location` tracking!
    // 4. Marshall return value back to a Rust type
}

So those functions do not really do much, and maybe it makes sense to mark all of them inline? I am not exactly sure.

For inlining the compiler will do this on its own and usually makes a good decision. So normally you don't need to annotate as inline.

2 Likes

The compiler will not inline over crate boundaries unless explicitly specified. No?

As of Rust 1.75 (over a year ago), cross-crate inlining is automatically enabled for “small” functions, so it is no longer necessary to write #[inline] to get any chance of inlining. Of course, that heuristic may not make the right decision for any particular function.

7 Likes

Also consider if you need track_caller at all. You're returning a Result, so if the caller wants that information it can get it anyway. track_caller is typically used for panics.

I think it makes sense when it is a Rust callback sent to C++. There, If Err case is returned by any FFI function, users are expected to short-circuit the call with ?. Which will then be handled by throwing an exception in C++, which will come back to Rust as an Err again at the bottom of the stack.

Currently the problem is that the final error will be a string saying something like "object not found". And I wanted to improve this part specifically.

I wanted to add some kind of adorn (or with_context as in anyhow) extension trait method that users can use to add exactly this kind of information, but this would require them using it for almost every single place Result is used, of which there are a lot.

For functions that are inlined there's no cost at all. Sometimes it may even slightly improve performance by reducing the number of unique calls to panic-starting functions.

For everything else it's a cost comparable to adding another function argument. This increases register pressure and stack usage slightly. It probably won't matter, because it's rare to have code that is performance sensitive but has many non-inlined calls.

3 Likes

Thanks!

Though, can you, please, elaborate a bit further on how track_caller can reduce the number of unique calls to panic-starting functions? Because I don't immediately see why this is the case.

Suppose we have two functions:

fn foo(...) {
    bar(...);
}

fn bar(input: ...) {
    input.baz().expect("bad bar()").qux().expect("bad bar()");
}

This program can panic from two sites, the two expects in bar, and therefore needs to know of two Locations to report in the panic output. If we add #[track_caller] to bar, then the only location remembered is line 2 where foo calls bar.

However, it seems likely to me that in most cases where this has any useful effect, it will also interfere with debugging when the panic does occur. It could reasonably help reduce code size in situations where there is a genuinely unreachable[1] panic, but in that case it is not appropriate to use #[track_caller] (the blame does not lie with the caller), and, what I would do instead is add a separate function:

fn bar(input: ...) {
    input.baz().unwrap_or_else(impossible).qux().unwrap_or_else(impossible);
}

#[cold]
fn impossible() -> ! {
    panic!("bar() went wrong")
}

This way, all the static panic info is completely identical in the two cases, and we are not mis-using #[track_caller].


  1. but not statically detectable as unreachable ↩︎

2 Likes