Skip to content

Add ILHookTransaction to batch per-target IL chain rebuilds - #313

Open
std-microblock wants to merge 2 commits into
MonoMod:reorganizefrom
std-microblock:ilhook-transaction
Open

std-microblock wants to merge 2 commits into
MonoMod:reorganizefrom
std-microblock:ilhook-transaction

Conversation

@std-microblock

@std-microblock std-microblock commented Aug 3, 2026

Copy link
Copy Markdown

Introduces an opt-in startup transaction that defers ILHook.Apply until a group of hooks can be committed together. Hooks targeting the same method are inserted into the hook graph in a single batch, so the source IL is cloned, every manipulator is replayed and the DynamicMethod is generated and JIT-compiled only once per target method instead of once per hook.

  • ILHook.Apply/Undo route through the transaction while it is active and IsApplied reflects the pending state.
  • DetourManager.AddILHooksBatch inserts a whole target group atomically and rolls the graph back if chain preparation fails.
  • Flush orders hooks by a stable (order, sequence) key and can serialize manipulators per owner key while running clone/generate/JIT outside the owner gate, preserving both per-mod and per-target hook ordering.

Outside an active transaction, ILHook behavior is unchanged.

Usage example: EverestAPI/Everest#1153

Note: This pull request is written under the assistance of AI

Introduces an opt-in startup transaction that defers ILHook.Apply until a
group of hooks can be committed together. Hooks targeting the same method
are inserted into the hook graph in a single batch, so the source IL is
cloned, every manipulator is replayed and the DynamicMethod is generated
and JIT-compiled only once per target method instead of once per hook.

- ILHook.Apply/Undo route through the transaction while it is active and
  IsApplied reflects the pending state.
- DetourManager.AddILHooksBatch inserts a whole target group atomically and
  rolls the graph back if chain preparation fails.
- Flush orders hooks by a stable (order, sequence) key and can serialize
  manipulators per owner key while running clone/generate/JIT outside the
  owner gate, preserving both per-mod and per-target hook ordering.

Outside an active transaction, ILHook behavior is unchanged.

@nike4613 nike4613 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Build is failing. Did you even try to build locally?

private static readonly object transactionLock = new();
private static readonly AsyncLocal<OrderContext?> currentOrder = new();
private static readonly ConcurrentDictionary<ILHook, ILHookTransaction> pendingOwners = new();
private static ILHookTransaction? activeTransaction;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So there can only ever be one active transaction globally, across all threads? That seems like a huge problem, especially considering there's no exposed way to synchronize against it; you can only try to start a transaction and see if it throws.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That's because I'm avoiding changing the interface of ILHook so that the Mod authors don't have to change their code to adopt to the feature. Would there be another better way? Thread local is also unacceptable because I'm parallelizing the mod main to get performance gains

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Do need more APIs to query transaction state and force flush indeed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we'd need to change the public API of ILHook. Consider:

var tns = ILHookTransaction.Begin();
tns.Apply(new ILHook(..., applyByDefault: false));
// ...
tns.Commit();

@std-microblock std-microblock Aug 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I don't think we'd need to change the public API of ILHook. Consider:

var tns = ILHookTransaction.Begin();
tns.Apply(new ILHook(..., applyByDefault: false));
// ...
tns.Commit();

Many mod still uses new ILHook(...).apply() way and this will also force them to change. I have an idea suddenly tho, is it better for us to pass transaction context through a global threadlocal as a hidden context? Like

var tns = ILHookTransaction.Begin();
new ILHook(...).apply();
tns.Commit();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

hmm, in the view of a universal hooking framework it do sounds strange to change the behaviors of .apply(), probably add a explicit option deferAllApplies in ILHookTransaction

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is the goal for this to be transparent to mod authors? That seems potentially dangerous, as it meaningfully changes behavior in a way that existing ambient state doesn't. (The existing DetourContext deals with exactly this kind of thing for automatically associated e.g. a mod ID with detours)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Is the goal for this to be transparent to mod authors?

yeah I designed it with this in mind. The reason why I modified monomod is actually to allow downstream modloaders(Everest for example) to speed up mod loading process by batching hook applies and avoid unnecessary decompilation/compilation, so it's not designed for a single mod to use(that won't have much effect) and I do want it to be transparent to mod authors

That seems potentially dangerous, as it meaningfully changes behavior in a way that existing ambient state doesn't. (The existing DetourContext deals with exactly this kind of thing for automatically associated e.g. a mod ID with detours)

What behavior? I actually have implemented a mechanism where calling a hooked function results in a instant flush if it's dirty, and as this is implemented the only observable side effect of enabling deferAllApplies is throwing exceptions in ilhook callbacks may not be catched by the try-catch block outside of hook.apply(); but ye that's a big chanhe and I'm still considering how to deal with that.

Comment on lines +118 to +124
public static IDisposable EnterOrder(long order)
{
var previous = currentOrder.Value;
currentOrder.Value = new OrderContext(order);
return new Scope(previous);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we have this ordering mechanism? Hooks and ILHooks already have ordering as part of their DetourConfig; the order of Apply() only matters when that's tied, and even then we make no guarantees. It seems strange, then, to try to expose that call ordering here as if its a contract that matters.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

In downstream PR I used this to ensure the hook applying order is the same as being applied serially. Yea that might not be guaranteed by monomod but I wanted to keep changes in behavior smaller to avoid having extra issues.

Comment on lines +271 to +278
private IDisposable EnterManipulatorGate(MonoMod.Cil.ILContext.Manipulator manipulator,
Func<MonoMod.Cil.ILContext.Manipulator, object?>? ownerSelector)
{
var owner = ownerSelector?.Invoke(manipulator) ?? manipulator.Method.DeclaringType?.Assembly;
var gate = owner is null ? unknownOwnerGate : ownerGates.GetOrAdd(owner, static _ => new object());
return new MonitorScope(gate);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This will cause the hooking process to take locks while already under other locks. This seems like it would be prone to deadlocks. Do we have any guarantee that it will not? (Why is this even necessary?)

@std-microblock std-microblock Aug 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This lock here is also for parallelizing the commiting process. It's used to keep commiting from the same assemblies serialed as we don't know if the hook callback the user wrotes is thread safe or not. If parallelism is removed, this can also be removed. #313 (comment)

Comment on lines +249 to +253
{
Parallel.ForEach(groups, new ParallelOptions {
MaxDegreeOfParallelism = maxDegreeOfParallelism
}, CommitGroup);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we want parallel commit? This seems like it could only cause problems.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

To make it faster..? In my tests it speed the commiting process up for like 2x under 2 parallelism and 2.5x under 4 parallelism, like 4 seconds to 2 seconds. Is it better to simply expose related api to user of the lib rather than do it here

MMDbgLog.Trace($"Applying ILHook for {Method}");
if (ILHookTransaction.TryQueue(this))
return;
state.AddILHook(hook, !lockTaken);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why are we doing implicit global-state transactions, instead of an explicit object which is given the hooks to apply? This seems like it would be very easy to get unintended and possibly wrong behavior, on top of being unintuitive.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same reason as #313 (comment)

@std-microblock

Copy link
Copy Markdown
Author

Build is failing. Did you even try to build locally?

hm, works on my machine, why

@std-microblock

Copy link
Copy Markdown
Author

Build is failing. Did you even try to build locally?

hm, works on my machine, why

oh that's bcuz the ci is using net35 and net452, time to upgrade xD

anyway if we decide to remove the parallelism part there should be no problem

@nike4613

nike4613 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Build is failing. Did you even try to build locally?

hm, works on my machine, why

oh that's bcuz the ci is using net35 and net452, time to upgrade xD

anyway if we decide to remove the parallelism part there should be no problem

The normal build process builds all target frameworks. CI just does dotnet build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants