Performance in D365 F&O is not just about writing efficient X++; it is about controlling the T-SQL that the kernel generates. When the system behaves erratically—fast one minute, slow the next—you are likely facing Parameter Sniffing.
In this post, we are going deep into the mechanics of the SQL Plan Cache and the specific X++ keywords (forceLiterals, forcePlaceholders, forceNestedLoop) that give you control over the execution plan.
The Mechanics of Parameter Sniffing
To fix the problem, we must understand the mechanics. When you execute an X++ query, the Kernel translates it into T-SQL. By default, it Parameterizes the query.
The Workflow:
- X++:
select * from SalesTable where SalesTable.PurchOrderFormNum == _poNum; - Kernel Translation:
SELECT * FROM SALESTABLE WHERE PURCHORDERFORMNUM = @P1 - SQL Server: Checks the Plan Cache.
- If a plan exists, it reuses it.
- If no plan exists, it "sniffs" the value of
@P1(e.g., 'PO-100') and compiles a plan based on that specific value's data distribution.
The Problem (Data Skew):
If the first query run after a server restart uses a value that returns 2 rows (Small Data), SQL creates a plan optimized for small data (e.g., Index Seek + Key Lookup).
If the second query uses a value that returns 1,000,000 rows (Big Data), SQL reuses the "Small Data" plan. Doing 1,000,000 Key Lookups will crash your performance.
1. The Weapon: forceLiterals
This is the most common fix for parameter sniffing. It instructs the D365 Kernel to bypass parameterization entirely for the selected table.
How it works
Instead of sending @P1, the kernel injects the literal value into the SQL string. This forces SQL Server to calculate a unique hash for the query text. Since the query text is different, SQL cannot find a cached plan and must compile a fresh plan using the current values.
X++ Implementation
// Scenario: Filtering by 'DataAreaId'.
// 'DAT' company has 100 rows. 'USMF' has 10 million rows.
public void processData(DataAreaId _company)
{
SalesTable salesTable;
// USE THIS:
// Forces the kernel to inject the literal string value
select forceLiterals count(RecId) from salesTable
where salesTable.DataAreaId == _company;
}
SELECT COUNT(RECID) FROM SALESTABLE WHERE DATAAREAID = 'USMF'Note: No @P1 parameter is used.
forceLiterals on high-frequency, low-latency queries (like Handheld scanning).
1. CPU Load: Compiling a plan takes CPU. Doing it 10 times a second will spike your DTUs/CPU.
2. Cache Bloat: You will fill the SQL Plan Cache with "Ad-Hoc" plans, flushing out other important plans.
Use Only: On queries with significant Data Skew (e.g., Status fields, Company IDs, Posted flags).
2. The Specialist: forcePlaceholders
This keyword is the inverse of forceLiterals. It is rarely used, but vital for high-performance integrations.
How it works
In complex scenarios—specifically when using ranges (QueryBuildRange) or huge `IN` clauses—the Kernel sometimes decides not to parameterize. It defaults to literals to help the optimizer.
However, if you have an API that hits an endpoint 50 times per second with different Item IDs, you want plan reuse. You do not want to compile 50 plans per second.
X++ Implementation
public void checkInventory(container _itemIds)
{
InventSum inventSum;
// Forces the kernel to use parameters (@P1, @P2...)
// ensuring the plan is cached and reused for the next API call.
select forcePlaceholders inventSum
where inventSum.ItemId in _itemIds;
}
3. The Algorithm Enforcer: forceNestedLoop
SQL Server joins tables using three main algorithms: Nested Loop, Hash Match, and Merge Join.
- Hash Match: Great for large sets joining to large sets. High memory usage (blocking).
- Nested Loop: Great for a small set (outer) joining to a large set (inner) via an index. Low memory, fast start.
Sometimes, if SQL Statistics are outdated, the Optimizer thinks a table is empty when it actually has 100k rows. It might choose a Hash Match when a Nested Loop is required. forceNestedLoop corresponds to the T-SQL hint OPTION (LOOP JOIN).
X++ Implementation
public void findSpecificReferences()
{
MyTransTable trans;
MyMasterTable master;
// We are iterating a transaction table and looking up master data.
// We want a fast, row-by-row lookup, not a massive hash operation in memory.
while select forceNestedLoop trans
join master
where trans.MasterRef == master.RecId
{
// ...
}
}
4. The Nuclear Option: forceSelectOrder
The SQL Optimizer is smart. It reorders your joins. Even if you write Table A join Table B join Table C, SQL might execute it as C -> B -> A based on which table is smallest.
Sometimes, it gets this wrong, especially with complex Views or Data Entities. forceSelectOrder corresponds to the T-SQL hint OPTION (FORCE ORDER). It tells SQL Server: "I know better than you. Join exactly in the order I wrote."
X++ Implementation
// Use this only when you have verified via Trace Parser
// that SQL is starting with the wrong table (e.g., scanning the biggest table first).
select forceSelectOrder * from smallDriverTable
join mediumTable
where mediumTable.Ref == smallDriverTable.Ref
join hugeTable
where hugeTable.Ref == mediumTable.Ref;
smallDriverTable becomes huge), this hard-coded hint will cause a massive performance degradation. Use as a last resort.
Summary: The Lead Developer's Checklist
When analyzing a performance regression, stop guessing and start analyzing the translation layer:
- Is the data skewed? (e.g., 90% of rows have one value). If yes, suspect Parameter Sniffing.
- Is it a 'One-Off' report or a high-frequency loop? If it's a report/batch job, use
forceLiterals. If it's a high-frequency loop, ensure parameterization is happening. - Check the Join Type: In Trace Parser, look at the execution plan. If you see a "Hash Match" on a simple lookup, test
forceNestedLoop.
Comments
Post a Comment