[Pitch] Swift Backtracing API

I think in practice it probably always wants to be 64-bit (which is the same solution that various Mach APIs have adopted for this exact problem).

I do think it should be a FixedWidthInteger type, mind, not an opaque struct or anything like that.

2 Likes

Even better!

Might I suggest just always using UInt64 for these values? That way we can guarantee* that it's always wide enough to hold an address even if it has crossed process boundaries (from 64-bit to 32-bit.) While that workflow is probably unlikely on Darwin, it could be very real when we consider Linux/Windows/etc. targets.

Note it's intentional that arm64_32 has 32-bit pointers in userspace, FWIW, so UInt is correctly sized there.

* Cue the inevitable "but my computer uses 128-bit pointers..."

3 Likes

This is already a realistic concern on RISC-V.

I think it’s reasonable to write tools to analyze backtraces on a 64-bit host from a 128-bit system under development.

I also think it’s unfair to penalize 32-bit processes with slower, double-wide integer math if all they’re doing is analyzing 32-bit stack traces.

Good that you thought of that. Worth verifying the API in other places where it is currently using "Int" (assuming the case of "analysing" process being 32-bit and "analysable" process being 64-bit). Perhaps those Int's are fine as offsets (as offsets can't be huge), just worth checking all places.

LGTM to use "typealias Address = (U)Int64" (and once 128 bit CPU's is a norm change it).

Making address opaque also makes sense, if for example you don't want users to suppose they can do arithmetics on it.

Perhaps over engineering, but also worth considering:

enum Address {
    case address32(Int32) // or UInt32 ?
    case address64(Int64) // or UInt64 ?
}

In practice, even if RISC-V has 128-bit pointers, it wouldn't actually have 128-bit addresses. Even today, there does not exist a 64-bit CPU that supports 64 bits of virtual address space (let alone of physical address space); I think it caps at 52 bits, although I should check if Intel came up with something to extend that. The top 12 bits of pointers are never used for addressing purposes on any existing platform, so effectively you can cut them off.

The idea for RISC-V's 128-bit pointers is that there's plenty of space for extra stuff that you might want to put in there, like bounds. Even though they're 128-bits, the address they represent is still going to be <= 64 bits for a very long time. It's also likely that a 128-bit pointer RISC-V ABI (on a 64-bit CPU) would still choose to use 64-bit return addresses because jump-and-link instructions still architecturally put the return address in a single register, not a pair.

1 Like

I think it’s unwise to assume that 128-bit CPUs will ever become the norm, in part because there’s no one norm that covers all of Swift’s potential install base.

It’s also very unlikely that 64-bit CPUs will completely eliminate 32-bit CPUs. 32-bit SoCs—and even 8-bit microcontrollers!—are still incredibly common in embedded applications. And 64-bit Windows machines still run plenty of 32-bit applications that may never be updated, but can certainly be debugged or crash.

This doesn’t solve the entire problem, because Address still requires an extra byte to hold the enum discriminator. But it’s better than forcing the compiler to issue 64-bit instructions on 32-bit targets just to move a high byte of zeroes around.

I wouldn’t necessarily rule out a one-off implementation. But your overall point is sound, and I think is evidence that Backtrace should be separately parameterized on word size and address size. The specialization of Backtrace that is available to a program for self-introspection would match the characteristics of the target architecture, but the actual API would be flexible enough for cross-compilation and debugging.

That raises the question of how flexible this parameterization would need to be. I mentioned 8-bit SoCs earlier, but 8- and 16-bit machines are so impractical for debugging that it wouldn’t make sense to use anything less than a 32-bit integer for holding addresses, even in a backtrace taken from an 8-bit CPU.

As for 36-bit target platforms, I guess the only practical choice is to use 64-bit integers even on a 32-bit host, as is already implicit for a “truly” 48-bit or 52-bit platform like ARMv8 with Top Byte Ignore or x86_64.

1 Like

When tracing code inside GCD block, would this API support capturing stack trace of the point where block was enqueued?

I'm not sure what is the correct term for this, in Xcode it looks like this:

This isn't something that the Swift backtracing API is likely to be much help with — it might be possible to use it to obtain a similar result, but you'd need to capture the backtrace when the dispatch_async() happened and associate it somehow with the block yourself. I think that's basically what Xcode is doing here, FWIW.

On the other hand, if you use Swift's async features instead of GCD, you will get the kind of behaviour you're after — as long as we're able to tell that we're looking at async frames, anyway.

2 Likes

Revisiting this, I've updated the Gist to reflect the present state of the prototype implementation. I don't think it's worth worrying about 128 bit addresses; the actual address bits in any 128-bit implementation are likely to fit in 64 bits anyway. On 32-bit platforms, using 64-bit addresses does waste some space, however it also avoids making everything generic. If the wasted space is genuinely an issue for someone, the data could be copied out into a more compact representation (in which case you could even do things like delta compressing the addresses, which would also save space on 64-bit systems).

3 Likes

Seems like a great addition to the stdlib. I'm happy to see this being pursued.

Capture-on-demand for images and shared cache info

  /// A list of captured images.
  ///
  /// Some backtracing algorithms may require this information, in which case
  /// it will be filled in by the `capture()` method.  Other algorithms may
  /// not, in which case it will be empty and you can capture an image list
  /// separately yourself using `captureImages()`.
  public var images: [Image]?

Why not just do captureImages implicitly if this property is fetched and not already calculated? Similarly for sharedCacheInfo.

SharedCacheInfo presence

In what situation would sharedCacheInfo return non-nil but have noCache be true? The uuid and baseAddress are non-optional, so what do they return when there is no shared cache?

Also, noCache is better phrased in the positive (per standard practice), e.g. exists or hasCache or similar.

/// On non-Darwin platforms it will always be `nil`.

Always? For all time? Is there some reason other platforms couldn't implement a shared cache at some point in the future? Perhaps this should say "Currently, only Darwin implements a shared cache" or "Currently this is only implemented (to return non-nil) for Darwin platforms" as appropriate.

Address formatting

formatAddress feels like it should be a member method of Address - and should perhaps adopt the format style pattern, so that there's formatted() (with suitable defaults) and format(_ style: …) for customisation. This might be at odds with having Address be merely an alias for an unsigned integer type. maybe it should be a real value type (implementing FixedWidthInteger etc so that it'll inherit relevant numeric value behaviours & compatibility).

Default capture length

I don't think there should be an implicit, finite limit on backtrace length. It should be either unlimited by default or not have a default. Otherwise - and especially with a non-tiny value as the default, like the current 64 - people might try to the API in simple tests, get complete backtraces and think everything's fine, only to later discover in production that they're missing data.

There could be some convenience capture methods tailored to specific purposes, instead. e.g. captureSummary which grabs the top and bottom 4 frames. And users could add their own with extensions.

Uncertain source locations

Should at least some of the properties of SourceLocation be optional? As previously discussed, sometimes they're not actually known (or not really valid, like the line0/column0 case).

Symbol aliasing / uncertainty

I tend to agree with earlier comments that symbol should really be symbols, in Frame, to allow for aliasing (or simple uncertainty given potentially limited or slightly incorrect debug info). Even if it's not common today, it could be moreso in future. That would also mean it could be non-optional since the empty case can serve for situations where symbol information isn't available / determinable.

Back-references to Images

Can Symbol contain a weak reference to the actual Image instead of just an index? That's not just easier to work with but less error-prone since you don't have to manually ensure you correctly match indices to arrays. It would mean Image has to be a reference type.

isSwiftRuntimeFailure

What's the difference between isSwiftRuntimeFailure on SymbolicatedBacktrace vs its Frame nested struct?

SymbolicatedBacktrace.sharedCacheInfo

Shouldn't sharedCacheInfo be optional on SymbolicatedBacktrace, for the same reason it is optional on Backtrace?

Formatting

It might be nice to have formatted(…) methods on Backtrace and SymbolicatedBacktrace, to customise the output. There's lots of different conventions and opinions on how they should be formatted - I'm not sure if the stdlib should have a strong opinion on this (which it does implicitly if its formatting isn't at least a little configurable).

1 Like

Because the Backtrace might be being constructed from data e.g. from a file, long after the process has executed. i.e. it may no longer be possible to fetch the information.

Equally doing the lazy thing you suggest might encourage people to not think too carefully before accessing the property. Capturing images is potentially quite an expensive operation, both in terms of memory footprint and the amount of work that is potentially required, and often you really only want to do it once.

I did contemplate this, but figured that being explicit was better here.

These questions are to do with internal implementation details in dyld. We're just capturing the information provided by the Darwin dynamic linker here and passing it through. The names of the members of the sharedCacheInfo structure likewise.

I don't think it's worth overcomplicating things here. formatAddress is only exposed publicly because it might be useful. I'd honestly rather make it private if it's going to drive people to demand something more complicated than a straightforward integer for Address.

I strongly disagree. With no limit, the capture operation could take an arbitrary amount of time (and potentially quite a long time in the face of extreme recursion). In principle it's even possible for an unwinder to get itself into some kind of infinite loop. If you don't want the limit, you should turn it off explicitly — which you can absolutely do if you want. But by doing that, you're implicitly accepting that it could take unbounded time, which is usually not what anyone would want.

This concern is also addressed to some degree by the top parameter, which makes sure that we will always capture some frames from the top of the stack as well as frames at the bottom.

I'm not sure it's terribly useful to distinguish between e.g. 0 and not present, which is what optionality would get you here. Maybe you feel we should map values less than or equal to 0 to nil? However, in that case we're potentially losing some information that was in the debug information.

I'm not sure exactly what you're referring to here, and in practice I don't think symbol aliases are a huge issue anyway — for a backtrace you just need to see a symbol. If it happens to have some aliases, you could always look at those with nm or objdump or other tools, but it doesn't fundamentally affect where you are in the code.

(Additionally, replacing a per-frame reference with an array isn't without cost.)

The reason it's like that is that Image is a struct rather than a class. Using a reference would require that Image be a class, which would mean allocating them and releasing them separately, which seems undesirable (in some cases there can be a lot of images).

The flag on the SymbolicatedBacktrace tells you if the entire backtrace was caused by a Swift runtime failure, which is true if the first frame is a Swift runtime failure frame (which, in turn is true if the symbol in question has a specific prefix and source location).

In most cases you'd be interested in the flag on SymbolicatedBacktrace and the other two are implementation details.

Nice spot. :-) That's a typo. It should already be optional.

I wanted to keep the proposal to the minimum API surface for now, because trying to expand it to cover everything would create a lot more debate and I think is better done in separate proposals.

If you're interested in seeing where I want to go with formatting, you could look at the BacktraceFormatter, which I think probably deserves an SE proposal all of its own before it's made API.

1 Like

If that's the case it'll just quickly return either what was imported alongside the backtrace else nil, won't it? I would assume that internally Backtrace would know whether it came from the current living process vs anywhere else, and not do inappropriate things in the latter case.

Though that does explain why there should be static member methods to capture the current images, in addition to any member variables for retrieving them for a specific Backtrace.

They shouldn't ask for the images if they don't need them.

It's possible it'll be misused more if its "just" a property access rather than an explicit method call, but even so I don't think that outweighs the awkwardness of making the API stateful.

As with the previously raised example about line0+column0, it would be nice to hide these warts of lower layers. In the case of shared cache info, it sounds like if the result from dyld is "no cache" then this property should just return nil, instead?

It should never be truly infinite though, should it? That to me would indicate a serious bug in the backtracer. It should be able to handle infinite loops (i.e. garbage data). And as long as it's bounded, I think it's fine by default. Nobody should be collecting backtraces this way in performance-sensitive code paths (or if you they are, they should at least be very explicitly configuring the capture operation to use fast mode and have very tight limits on frame count etc - but only those people in those situations can know what the best numbers are).

Which is indeed very helpful and wise to include. Although the same problem applies - your default is never going to be broadly 'correct'. e.g. 8 frames might capture the entire program in any simple CLI program, but it doesn't capture anything useful in a typical SwiftUI app. Having no limit (by default) on frame count neatly sidesteps the issue of what a sensible top default is, since top only applies if limit is finite.

It's about how it's used, and how explicit it is to the user what the semantics are.

A non-optional value means the value is always valid, by definition (in Swift, with legacy & imported C APIs notwithstanding). But 0 (or a negative number) isn't actually a valid source line. If the user has to unwrap it first (or provide a suitable context-specific alternative via ??) then the ultimate result will be better.

Furthermore, requiring the user to employ if line > 0… checks is less robust than simple nil handling.

I realise there's some degree of subjectiveness to how "opinionated" APIs should be in this regard, but I think Swift has been pretty clear that it favours correctness (and clarity) over convenience. So being precise about these semantics is in line with Swift's principles.

Is a struct more efficient, though? Image contains five value types which are each at least eight bytes each (three are CoW types so they contain at least a pointer plus other metadata, two are pointer-sized integers). So 40 bytes minimum. And these Images will be the same for virtually all Backtraces within a program - but will appear in numerous places, not just as a single Array that can be internally refcounted and implicitly maintained as a singleton. I wouldn't be so sure the cost of ref-counting them will be worse than copying them.

It might even make the most sense to make Images immortal classes (stored in some interning table) since:

  • In some programs you'll only need them when the program's about to abort anyway.
  • Otherwise, in cases where the backtrace is not associated with an essentially fatal event, then you'll probably do a bunch of backtraces over the life of the app, so you'll amortise the cost of building the Images and ensure memory efficiency.

I don't feel strongly about this (especially the immortal idea - I'm just saying it's worth considering), but I do think API safety and ergonomics should be preferred over undemonstrated performance costs.