When FFI Function Calls Beat Native C
nullprogram.com
nullprogram.com
The timing used here in this post is ns per call, which somewhat obfuscates what's going on. A better metric would be clock cycles of overhead, because the differences here are only around 2-3 clock cycles. In other words, the potential savings is going to be drowned out by jitter caused by things like the processor deciding to service interrupts, or your hyperthread partner doing work. If the relevant code is so hot that the cost is showing up in profiles, then very likely, the fact that a function call is going through the PLT versus being directly called is not going to be the thing that makes the most difference: it's the fact that you're making the function call in the first place.
The reason the effect seems bigger than it is is that for all but the most trivial leaf functions, PLT indirection just isn't a big deal. Never has been, isn't now, never will be. It's a cool trick that a JIT can skip that step, but there are bigger fish to fry.
The whole GOT/PLT thing is kind of C/C++ specific, since that's compiled into the calling code, and supports specific C ABI features like symbol interposition, and so is not a necessary component of calling a dynamic library. Especially JIT-compiled languages don't really need either of the features the GOT or PLT offer.
I think the main performance are really (1) dynamically linked C/C++ with GOT + PLT, (2) statically linked C/C++ and then (potentially) separate categories for the FFI mechanism for various high level languages, which may happen to coincide with (1) or (2) or have some other behavior.
Certainly the very slow results for some languages indicate that they are doing something other than a plain call via mechanisms (1) or (2).
Of course, the GOT referred to by the PLT is itself is a bit of a security vulnerability, because it's usually a writable chunk of memory with a ton of frequently-called function pointers; corrupting it gives you relatively easy control over program flow. The new mitigation is "full RelRO", which resolves all GOT entries during binary load, then marks the GOT segment read-only. This gains in security, but trades off binary load time, and it is still going to be slower at runtime than the JIT. Full RelRO is usually off by default because of this added overhead.
Modifying the code segment to include direct function references (either during binary load or later, during runtime) is also not a great approach because it means being unable to share the code segment between processes, which would increase memory pressure. We could do what Windows does, which is to have static randomization (randomization of binary addresses is performed once per boot, not once per process), enabling code to be "statically relocated" lazily per boot, but this carries its own complexities, and opens you up to different security vulnerabilities.
There's no magic bullet here, unfortunately. There are just tradeoffs everywhere in many directions - between runtime speed, startup time, security and memory usage.
The cost of indirect jumps isn't implied by position independent (PIE/PIC) code alone, but by PIE/PIC combined with jumps across separately loadable segments. For jumps _within_ a segment you can just use a direct relative call which is the fastest type of call and doesn't require any load-time fixup ever.
Of course then there is also the consideration of symbol interposition: if you want to support that even _within_ a statically linked thing (usually something other than the main executable), then you need a GOT-like thing, but not necessarily a PLT-like thing.
One big factor of direct calls vs indirect calls is the CPU i-cache, which preloads the direct call, but not the indirect call.
The first call always takes a big hit, only then it will be cached and subsequent calls are about the same speed is direct calls, with 1-2 cycs overhead. The address rarely changes, so it will always be in the first level BTB. But still.
The system call would map in a new non-shared page which the kernel has dynamically generated from the supplied mappings. The user-land never sees the page as writable, only as executable, the kernel generates only jump instructions, and limits this behaviour to specific pages determined when the executable is loaded.
Realistically how big of a deal is this? I feel like it's overblown, no? Literally the only huge program I have that sneezes out a gazillion instances of itself is Chrome. On everything else there's usually only one and most a few instances.
A sophisticated JIT could even keep track on how frequently the fast track is taken and generate a new stub based on that. It could even inline the stub into the calling function and recompile that one.
Er...a big problem that all JITted VMs share. For one, not permitted on iOS, and memory pressure from dirty pages is much bigger than from clean pages, exacerbated by the fact that they're not shared.
In C, this works fine. But in C++, gcc generates the GOT for virtual method calls but there is no ELF loader to fill it.
I accidentally found out if my code contains a destructor that makes an assignment to a static variable then no GOT is generated and virtual method calls work for the entire project! I'm not sure the logic behind that but this is the code that is needed:
class SampleObject
{
public:
//! Destroys the underlying Pebble window
virtual ~SampleObject()
{
// The assignment to a static variable makes the virtual methods work for subclasses
// I have no idea why!
dummy() = 0;
}
private:
// Dummy static variable needed for virutal method pointers to work (see destructor)
inline static int& dummy() { static int dummy = 0; return dummy; }
}Yes, the JIT is able to take advantage of runtime code generation to embed the final loaded address in the compiled assembly, which is something C _cannot do_ or at least does not do in its default configuration to support PIC, symbol interposition and other features.
JITs can have all the same support, but because of runtime code generation (which comes with its own costs) the final call-site can be more efficient.
So it's a reasonable rule of thumb, but it has plenty of exceptions. Other examples include JITs creating code optimized to the runtime-observed data distribution and JITs efficiently using specific hardware features of the current hardware rather than statically compiling in lowest-common-denominator or trying to use runtime dispatch.
jit: 1.433856 ns/call
plt: 1.700611 ns/call
ind: 1.404397 ns/call
The PLT is double-indirected (call->jmp->func), but the single-indirect call performs the same as the JIT. This backs up @tlb's note that modern x86 machines will predict indirect calls.tl;dr: getting the function address first with dlsym or using -fno-plt does close the gap.
Before:
cc -shared -fPIC -Os -s -o empty.so empty.c
cc -std=c99 -Wall -Wextra -O3 -g3 -fpie -pie -o benchmark benchmark.c ./empty.so -ldl
jit: 1.541608 ns/call
plt: 2.309939 ns/call
ind: 1.540583 ns/call
After: cc -shared -fPIC -Os -s -o empty.so empty.c
cc -std=c99 -Wall -Wextra -O3 -g3 -fpie -fno-plt -pie -o benchmark benchmark.c ./empty.so -ldl
jit: 1.541836 ns/call
plt: 1.539239 ns/call
ind: 1.542874 ns/callIt may be an interesting article for describing how JIT and some late-optimization techniques can produce optimized code given the right optimization preconditions, but I fail to see how the title is supposed to describe the contents of the article.
Last, but not least, performance-sensitive C code tends to be statically linked.
Back in the 90s we would statically link performance-sensitive executables to avoid this overhead. It didn't require changing the language.
> The most surprising result of the benchmark is that LuaJIT’s FFI is substantially faster than C.
It's just that the way it happens to do the call is faster than C calling a function in a shared object.
The benchmark didn't even include a static linking case! In general, however, I expect the static linking case to perform like the JIT case since both will almost always use a call with 32-bit relative immediate jump. I was a bit confused about why the article author chose to write a JIT rather than just use static linking, since I think they achieve the same thing here.
A C program, that self modifies as it runs, to inline commonly called functions, or make direct jumps instead of indirect.
Without W^X, many simple bugs can easily become bad exploits. You can inject some code into memory (using any program input) and then use the bug to cause it to be executed: immediate control.
If W^X is in place, then the only code that could be executed by an exploit is code that is already there. Techniques like ROP allow this to be exploited still, but it's much harder and needs more of security hole to start with.
The sort of thing Panama is adding is support for layouts of structures so that you can get results to and from C quickly, along with things like expressing vector algorithms in a way that allows good optimisation on different CPUs.
ELF != C
C does not imply ELF
ELF does not imply C
This is about entirely and only dynamic linking vs. static linking. Selling it as anything else is bunk and click-bait.And yes, dlsym(3) should return the address of the PLT, not the direct address of the function: so that dlsym(foo, "bar") == bar (when you can link with whatever object provides "bar").
That Mike guy knows a thing or two I reckon.