Is there a reason why we can’t have callbacks for unexported functions

In Overview — Erlang System Documentation v28.1 it’s mentioned that code written without behaviors can be more efficient, this is obviously due to the requirement of exporting functions which are slower and generate bigger code as the compiler can’t optimize it.

Code written without using behaviours can be more efficient, but the increased efficiency is at the expense of generality. The ability to manage all applications in the system in a consistent manner is important.

Using behaviours also makes it easier to read and understand code written by other programmers. Improvised programming structures, while possibly more efficient, are always more difficult to understand.

Although I do not understand this limitation, what if you want a user to manipulate a function in a hot loop, obviously you don’t want the user to have suboptimal code or a worse UX. But that is currently the state of things. I have a use case: sql/lib/token.ex at d1ae6ce3c934adffbfd31f084dc857c2007aec6b · elixir-dbvisor/sql · GitHub where I have to jump through hoops to make everyone happy, I have personally experienced an order of magnitude difference in using un exported code for hot loops, not even force inlining exported functions help here.

So is there any path to support this use case?

The hive mind here would say that you shouldn’t have optimization on your mind.

To paraphrase Joe Armstrong: first make it work, then make it correct, only then make it fast. You will probably never get to stage 3.

2 Likes

That is exactly what I’ve followed and why I found out about this issue.

Do you have a minimal example reproducing the order of magnitude difference?

To answer the original question:

First of all, Erlang doesn’t have the concept of “callback” per se. -behavior attribute only affects static checks, and it doesn’t influence code generation in any way, as far as I know. When you specify that certain module implements a behavior (for example, by writing -behavior(gen_server).), the compiler will generate warnings if the module doesn’t export functions specified using -callback attribute in the module gen_server. That’s it. Erlang behavior is not some magic language feature, it’s merely a way to specify contract of a module interface. Since un-exported functions are not module interface, the question doesn’t make much sense.

To understand how behaviors work, consider the simplest example:

-module(foo).

-export([apply_foo/2]).

%% Declare that any module implementing `foo' behavior must export function `foo/1':
-callback foo(term) -> term().

-spec apply_foo(module(), term()) -> term().
apply_foo(Module, Input) ->
  Module:foo(Input).

This is the most primitive example of a behavior module, but “real” behaviors like gen_server use the callbacks in a similar fashion. Notice three things:

  1. It’s entirely possible to omit -callback attribute, and it won’t affect how any code works in the runtime.
  2. Invoking “callback” foo is a simple function call. There’s nothing special about Mod:foo call that makes it a “callback”.
  3. Obviously, you cannot use an un-exported function in this setting, because it’s, well, not exported.

Now, regarding performance.

Different ways of invoking a function do indeed have different overhead, see Erlang -- Functions . But calling a function is relatively cheap regardless of the method. Practically speaking, you can only notice the difference in a synthetic benchmark that repeatedly calls a function that does nothing. Also notice that gen_server module “caches” some callback funs in its state. I’m not sure whether it’s done for performance or some other reason.

I can speculate that there’s only one scenario where external function calls may take a very long time to complete: when calling a non-existent module in interactive mode. Then code loader may repeatedly try to load the module, and it will involve scanning the search path and dealing with the file system. Since the only outcome of such call is raising an exception, I would argue this is a pathological case.

With this information in mind, you can write generic algorithms using internal functions: you just write a higher-order function taking local function(s) as argument(s).

1 Like

I don’t think this is what is meant. But I may be wrong, the documentation is a bit ambiguous here. Maybe someone like @raimo or @bjorng can clarify?

Rather, I think the efficiency penalty the passage talks about is due to the fact that a behavior module (like gen_server) poses an overhead, and also offers some features which a specific implementing callback module may not need or use, but which need to be handled anyway.

Using gen_server for illustration, an incoming message needs to be examined to decide whether it is a call, cast or info message (it could also be a system message or an 'EXIT' from a supervisor, which also need to be provided for).

The returns from the callback functions that handle a message must also be examined, reply or noreply or stop (with a reply or without), with a timeout or hibernate or continue or without…

There is probably a lot more that gen_server provides for. All that comes at a price, compared to which the price for calls to exported function pales.

A custom module in plain Erlang OTOH can be streamlined to only implement stuff it actually uses, and abandon the stuff it doesn’t need, thereby avoiding code to handle things that you know never happens there. Thus, such a hand-tailored module can be more efficient than a behavior callback module that is in comparison bloated with useless things.

3 Likes

But if you do a custom module then there are a lot of things you will need to implement, and get right, yourself.

2 Likes

Part of that overhead is the fact that behaviour modules are addressed symbolically by name, so calling their callbacks ends up doing (the equivalent of) apply(Mod, func, […]) which is going to be more expensive than any direct <known-module>:func(…) call since it has to do a lookup in the global export table.

I think gen_server got an optimization in this area a few years ago (pre-compute the callback funs) but that’s not applicable to all generic behaviours.

I’m not suggesting to avoid generic behaviours over this, but it may be an avenue to explore if you do have performance issues in this area.

Partial evaluation is a thing. One could imagine an ‘Erlmix’ that took a behaviour module and a module that instantiated that behaviour, wove them together, and simplified, deleting unused code, pruning export lists (see my rather old -export_to proposal), and at a very minimum turning indirect calls into direct ones. It doesn’t have to be done manually. Indeed, this would be well within the scope of an advanced student project. How much benefit you would get from this is an empirical question.

1 Like

You mean this PR I believe?

I would guess that the administrative overhead from what a behaviour needs to do is the biggest reason for behaviours being slower than a streamlined server.

If you look at how much code that is traversed in gen_* before coming back to the receive statement it is quite telling that it must take execution time.

gen_statem, for example, has got much more code for decoding the callback module’s response, a much bigger state of its own that has to be picked apart and reassembled in the loop, so it is noticeably slower than gen_server.

Using proc_lib, have your own receive statement, and handle system messages, is a middle way to write a supervision tree compatible service, which is as fast as it can get given that requirement, and it is also not extremely complicated.

2 Likes